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
+31 -5
View File
@@ -94,10 +94,21 @@ async function applyAdvertisementFields(advertisement, body) {
// status is intentionally NOT settable here — it's derived via deriveStatus()
// right before save, based on is_active + start_date/end_date.
if (body.content_mode !== undefined) {
if (!["image", "content"].includes(body.content_mode)) {
const err = new Error(`Invalid content_mode. Must be one of: image, content`);
err.status = 400;
throw err;
}
advertisement.content_mode = body.content_mode;
}
if (body.badge_label !== undefined) advertisement.badge_label = body.badge_label;
if (body.headline !== undefined) advertisement.headline = body.headline;
if (body.description !== undefined) advertisement.description = body.description;
if (body.image_url !== undefined) advertisement.image_url = body.image_url;
if (body.redirect_link !== undefined) advertisement.redirect_link = body.redirect_link || null;
if (body.landing_page !== undefined) advertisement.landing_page = body.landing_page || null;
if (body.image_asset_id !== undefined) {
if (body.image_asset_id === null) {
@@ -136,8 +147,27 @@ async function applyAdvertisementFields(advertisement, body) {
// ─── GET ALL ──────────────────────────────────────────────────────────────────
// Keeps the stored `status` column in sync with deriveStatus() before the
// filtered query runs — status is otherwise only recomputed on individual
// row reads, so filtering by status (e.g. "expired") would miss rows whose
// start_date/end_date lapsed since they were last saved.
async function syncDerivedStatuses() {
await sequelize.query(`
UPDATE advertisements
SET status = CASE
WHEN is_active = false THEN 'draft'
WHEN end_date IS NOT NULL AND end_date < NOW() THEN 'expired'
WHEN start_date IS NOT NULL AND start_date > NOW() THEN 'scheduled'
ELSE 'active'
END
WHERE "deletedAt" IS NULL
`);
}
exports.getAdvertisements = async (req, res) => {
try {
await syncDerivedStatuses();
const result = await paginate(Advertisement, req, {
excludeAttributes: adminExclude,
jsonbSchemas,
@@ -275,10 +305,6 @@ exports.updateAdvertisement = async (req, res) => {
}
};
// TODO(ads-7): Once expired (end_date passed), an advertisement should be
// auto-archived (soft-deleted via the same path as archiveAdvertisement below)
// instead of just sitting at derived status "expired" indefinitely. Add a
// cron job — see TODO(ads-7) in new_starr/cron/client.cron.js.
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
exports.archiveAdvertisement = async (req, res) => {
@@ -382,7 +408,7 @@ exports.getArchivedAdvertisements = async (req, res) => {
excludeAttributes: adminExclude,
jsonbSchemas,
computedAttributes,
context: "list",
context: "archived",
auditOptions: { mdl_Users, parentAlias: 'Advertisement' },
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
});
+3 -3
View File
@@ -14,7 +14,7 @@ const { flattenUnits, flattenLessons, nextOrderIndex, reorderJunction } = requir
const { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { renderNotification } = require('../../services/notificationTemplate.service');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
// ── Models ────────────────────────────────────────────────────────────────────
@@ -2174,11 +2174,11 @@ exports.updateAssessment = async (req, res) => {
where: { course_id: courseId },
attributes: ['title', 'uuid'],
});
const notify = await renderNotification({ type: 'assessment_updated', data: {
const notify = NOTIFICATION_REGISTRY.assessment_updated.build({
assessmentTitle: assessment.title,
courseTitle: course?.title ?? null,
courseUuid: course?.uuid ?? null,
} });
});
const now = new Date();
await UserNotification.bulkCreate(
inProgressSessions.map(({ user_id }) => ({
+53 -11
View File
@@ -12,7 +12,44 @@
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
const AdminNotification = require('../../models/notifications/admin_notification.mdl');
const StickyBannerSetting = require('../../models/notifications/sticky_banner_setting.mdl');
const mdl_Assets = require('../../models/assets/assets.mdl');
const mediaToken = require('../../services/mediaToken.service');
const R = require('../../utils/response.util');
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
const STICKY_LIMIT = 3;
const IMAGE_INCLUDE = {
model: mdl_Assets,
as: 'image',
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"],
required: false,
};
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken.
async function attachImageStreamToken(image, req) {
if (!image || image.storage_provider !== 's3' || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
return image;
}
const ip = mediaToken.resolveIp(req);
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
image.stream_token = token;
image.file_url = null;
image.thumbnail_url = null;
delete image.storage_key;
return image;
}
// One shared banner image for the whole rotating sticky bar (see
// controllers/admin/notificationBroadcasts.controller.js's
// getStickyBannerSetting/updateStickyBannerSetting) — not per-announcement.
async function resolveSharedBannerImage(req) {
const setting = await StickyBannerSetting.findOne({ where: { id: 1 }, include: [IMAGE_INCLUDE] });
if (!setting?.image) return null;
const image = setting.toJSON().image;
await attachImageStreamToken(image, req);
return image;
}
// ─── GET /admin/notifications ─────────────────────────────────────────────────
async function list(req, res) {
@@ -25,7 +62,7 @@ async function list(req, res) {
order: [['createdAt', 'DESC']],
limit,
offset,
where: { show_in_notifications: true },
where: { show_in_notifications: true, ...notInFutureOrExpired() },
});
return R.success(res, 'Notifications fetched.', {
@@ -41,7 +78,7 @@ async function list(req, res) {
// ─── GET /admin/notifications/unseen ─────────────────────────────────────────
async function unseenCount(req, res) {
try {
const count = await AdminNotification.count({ where: { seen: false, show_in_notifications: true } });
const count = await AdminNotification.count({ where: { seen: false, show_in_notifications: true, ...notInFutureOrExpired() } });
return R.success(res, 'Unseen count fetched.', { count });
} catch (err) {
console.error('[NOTIFICATION] unseenCount error:', err);
@@ -54,16 +91,21 @@ async function unseenCount(req, res) {
// banner for every admin. Whoever dismisses it first dismisses it for all.
async function stickyAnnouncement(req, res) {
try {
const notification = await AdminNotification.findOne({
where: {
seen: false,
show_in_sticky: true,
type: 'announcement',
},
order: [['createdAt', 'DESC']],
});
const [notifications, bannerImage] = await Promise.all([
AdminNotification.findAll({
where: {
seen: false,
show_in_sticky: true,
type: 'announcement',
...notInFutureOrExpired(),
},
order: [['createdAt', 'DESC']],
limit: STICKY_LIMIT,
}),
resolveSharedBannerImage(req),
]);
return R.success(res, 'Sticky announcement fetched.', { announcement: notification });
return R.success(res, 'Sticky announcements fetched.', { announcements: notifications, bannerImage });
} catch (err) {
console.error('[NOTIFICATION] stickyAnnouncement error:', err);
return R.error(res, 'Failed to fetch sticky announcement.');
@@ -4,16 +4,20 @@ const sequelize = require("../../config/db.config");
const NotificationBroadcast = require("../../models/notifications/notification_broadcast.mdl");
const AdminNotification = require("../../models/notifications/admin_notification.mdl");
const UserNotification = require("../../models/notifications/user_notification.mdl");
const StickyBannerSetting = require("../../models/notifications/sticky_banner_setting.mdl");
const mdl_Users = require('../../models/users/users.mdl');
const mdl_Assets = require('../../models/assets/assets.mdl');
const { TaskList } = require('../../models/task/task.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { Course } = require('../../models/courses/courses.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mediaToken = require("../../services/mediaToken.service");
const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/notifications/notification_broadcast.attributes");
const logActivity = require('../../utils/logActivity.util');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
const {
ALLOWED_TARGET_TYPES,
SCOPED_TARGET_TYPES,
@@ -28,10 +32,58 @@ const { Op } = require('sequelize');
const notDeleted = { deletedAt: null };
// Fields needed off the associated Asset to render the shared sticky banner
// preview AND (for S3 assets) mint a stream token — mirrors advertisements.controller.js.
const IMAGE_ATTRIBUTES = ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"];
const IMAGE_INCLUDE = { model: mdl_Assets, as: "image", attributes: IMAGE_ATTRIBUTES, required: false };
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken
// — kept duplicated rather than shared (same rationale used there).
async function attachImageStreamToken(image, req) {
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
return image;
}
const ip = mediaToken.resolveIp(req);
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
image.stream_token = token;
image.file_url = null;
image.thumbnail_url = null;
delete image.storage_key;
return image;
}
// "Active sticky" = live in the rotating sticky banner right now: sent,
// show_in_sticky, not archived, and within its own start/end window. Caps the
// bar at 3 concurrent slots (see sendBroadcast/updateBroadcast below).
async function countActiveSticky(excludeId = null) {
return NotificationBroadcast.count({
where: {
status: 'sent',
show_in_sticky: true,
...notDeleted,
...notInFutureOrExpired(),
...(excludeId ? { broadcast_id: { [Op.ne]: excludeId } } : {}),
},
});
}
const MAX_ACTIVE_STICKY = 3;
const ACTIVE_STICKY_CAP_MESSAGE = `Maximum of ${MAX_ACTIVE_STICKY} active sticky announcements right now — this stays in Draft until one ends or is archived.`;
async function applyBroadcastFields(broadcast, body) {
if (body.title !== undefined) broadcast.title = body.title;
if (body.message !== undefined) broadcast.message = body.message;
if (body.link_url !== undefined) broadcast.link_url = body.link_url?.trim() || null;
if (body.link_label !== undefined) broadcast.link_label = body.link_label?.trim() || null;
if (body.color !== undefined) broadcast.color = body.color || 'indigo';
if (body.start_date !== undefined) broadcast.start_date = body.start_date || null;
if (body.end_date !== undefined) broadcast.end_date = body.end_date || null;
if (broadcast.start_date && broadcast.end_date && new Date(broadcast.start_date) > new Date(broadcast.end_date)) {
const err = new Error("Start date must be before end date.");
err.status = 400;
throw err;
}
if (body.show_in_sticky !== undefined) broadcast.show_in_sticky = !!body.show_in_sticky;
if (body.show_in_notifications !== undefined) broadcast.show_in_notifications = !!body.show_in_notifications;
@@ -161,11 +213,15 @@ exports.createBroadcast = async (req, res) => {
title,
message,
link_url,
link_label,
color,
target_type,
target_id,
createdBy,
show_in_sticky,
show_in_notifications,
start_date,
end_date,
} = req.body;
if (!title) return R.error(res, "title is required.", 400);
@@ -181,6 +237,10 @@ exports.createBroadcast = async (req, res) => {
return R.error(res, "At least one of show_in_sticky or show_in_notifications must be enabled.", 400);
}
if (start_date && end_date && new Date(start_date) > new Date(end_date)) {
return R.error(res, "Start date must be before end date.", 400);
}
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
const t = await sequelize.transaction();
@@ -189,6 +249,10 @@ exports.createBroadcast = async (req, res) => {
title,
message,
link_url: link_url?.trim() || null,
link_label: link_label?.trim() || null,
color: color || 'indigo',
start_date: start_date || null,
end_date: end_date || null,
createdBy,
status: 'draft',
target_type,
@@ -222,7 +286,7 @@ exports.updateBroadcast = async (req, res) => {
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be edited.", 400);
const wasActiveSticky = broadcast.status === 'sent' && broadcast.show_in_sticky;
const t = await sequelize.transaction();
try {
@@ -232,8 +296,54 @@ exports.updateBroadcast = async (req, res) => {
err.status = 400;
throw err;
}
// Editing a live broadcast to newly flip on show_in_sticky is the same
// "activate a sticky slot" action as sendBroadcast — must respect the
// same 3-slot cap, or it's a trivial bypass.
if (broadcast.status === 'sent' && broadcast.show_in_sticky && !wasActiveSticky) {
if ((await countActiveSticky(broadcast.broadcast_id)) >= MAX_ACTIVE_STICKY) {
const err = new Error(ACTIVE_STICKY_CAP_MESSAGE);
err.status = 409;
throw err;
}
}
broadcast.updatedBy = req.body.updatedBy ?? null;
await broadcast.save({ transaction: t });
// Already-sent broadcasts have per-recipient rows created at send time
// (see sendBroadcast) — propagate content/display edits into them so
// changes show up immediately for anyone currently seeing it. Target/
// audience fields are deliberately NOT propagated (see plan notes):
// recipients were already resolved, and task_list's per-user groupId
// deep-link (stored in each row's own `data`) must not be clobbered.
if (broadcast.status === 'sent') {
const propagated = {
title: broadcast.title,
message: broadcast.message,
color: broadcast.color,
show_in_sticky: broadcast.show_in_sticky,
show_in_notifications: broadcast.show_in_notifications,
start_date: broadcast.start_date,
end_date: broadcast.end_date,
linkUrl: broadcast.link_url,
linkLabel: broadcast.link_label,
broadcastId: broadcast.broadcast_id,
};
for (const table of ['admin_notifications', 'user_notifications']) {
await sequelize.query(
`UPDATE ${table}
SET title = :title, message = :message, color = :color,
show_in_sticky = :show_in_sticky, show_in_notifications = :show_in_notifications,
start_date = :start_date, end_date = :end_date,
data = data || jsonb_build_object('linkUrl', :linkUrl, 'linkLabel', :linkLabel)
WHERE broadcast_id = :broadcastId`,
{ replacements: propagated, transaction: t }
);
}
}
await t.commit();
logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
@@ -260,6 +370,10 @@ exports.sendBroadcast = async (req, res) => {
if (!broadcast) return R.error(res, "Announcement not found.", 404);
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be sent.", 400);
if (broadcast.show_in_sticky && (await countActiveSticky(broadcast.broadcast_id)) >= MAX_ACTIVE_STICKY) {
return R.error(res, ACTIVE_STICKY_CAP_MESSAGE, 409);
}
const t = await sequelize.transaction();
try {
const now = new Date();
@@ -275,12 +389,13 @@ exports.sendBroadcast = async (req, res) => {
message: broadcast.message,
targetType,
targetId,
linkUrl: broadcast.link_url,
linkUrl: broadcast.link_url,
linkLabel: broadcast.link_label,
});
if (targetType === 'admin' || targetType === 'both') {
await AdminNotification.create(
{ ...baseNotify, seen: false, show_in_sticky: showInSticky, show_in_notifications: showInNotifications },
{ ...baseNotify, seen: false, show_in_sticky: showInSticky, show_in_notifications: showInNotifications, color: broadcast.color, start_date: broadcast.start_date, end_date: broadcast.end_date, broadcast_id: broadcast.broadcast_id },
{ transaction: t }
);
recipientCount += 1;
@@ -312,7 +427,8 @@ exports.sendBroadcast = async (req, res) => {
? NOTIFICATION_REGISTRY.broadcast.build({
title: broadcast.title, message: broadcast.message, targetType, targetId,
groupId: groupByUser[user_id] ?? null,
linkUrl: broadcast.link_url,
linkUrl: broadcast.link_url,
linkLabel: broadcast.link_label,
})
: baseNotify),
seen: false,
@@ -320,6 +436,10 @@ exports.sendBroadcast = async (req, res) => {
updatedAt: now,
show_in_sticky: showInSticky,
show_in_notifications: showInNotifications,
color: broadcast.color,
start_date: broadcast.start_date,
end_date: broadcast.end_date,
broadcast_id: broadcast.broadcast_id,
})),
{ validate: false, transaction: t }
);
@@ -505,3 +625,62 @@ exports.permanentlyDeleteBroadcasts = async (req, res) => {
return R.error(res, "Could not permanently delete announcements.", 500);
}
};
// ─── STICKY BANNER (shared, singleton) ────────────────────────────────────────
// One image for the whole rotating sticky bar (up to 3 concurrent
// announcements share it) — not one per announcement. Set from the
// Announcements list page.
exports.getStickyBannerSetting = async (req, res) => {
try {
const setting = await StickyBannerSetting.findOne({ where: { id: 1 }, include: [IMAGE_INCLUDE] });
if (!setting) return R.success(res, "Sticky banner setting retrieved.", { data: null });
const json = setting.toJSON();
if (json.image) await attachImageStreamToken(json.image, req);
return R.success(res, "Sticky banner setting retrieved.", { data: json });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET STICKY BANNER]", err);
return R.error(res, "Could not retrieve sticky banner setting.", 500);
}
};
exports.updateStickyBannerSetting = async (req, res) => {
try {
const { image_asset_id, updatedBy } = req.body;
let validatedImageAssetId = null;
if (image_asset_id) {
const asset = await mdl_Assets.findOne({ where: { asset_id: image_asset_id, deletedAt: null } });
if (!asset) return R.error(res, "Selected image asset was not found.", 400);
validatedImageAssetId = asset.asset_id;
}
// Plain find-then-create/update rather than findOrCreate() — Sequelize's
// postgres findOrCreate() relies on a temp PL/pgSQL function for atomicity
// that CockroachDB doesn't support ("cannot create user-defined functions
// under a temporary schema") — same fix as trustedDevice.service.js.
let setting = await StickyBannerSetting.findOne({ where: { id: 1 } });
if (setting) {
setting.image_asset_id = validatedImageAssetId;
setting.updatedBy = updatedBy ?? null;
await setting.save();
} else {
setting = await StickyBannerSetting.create({ id: 1, image_asset_id: validatedImageAssetId, updatedBy: updatedBy ?? null });
}
// Reload with the image association so the response carries a fully
// resolved preview (stream token for S3) — same shape as the GET, so the
// frontend never needs to locally guess/merge in an optimistic image.
await setting.reload({ include: [IMAGE_INCLUDE] });
const json = setting.toJSON();
if (json.image) await attachImageStreamToken(json.image, req);
logActivity(req.user?.user_id, 'update_sticky_banner_setting', { entityType: 'sticky_banner_setting', entityId: 1 });
return R.success(res, "Sticky banner setting updated.", { data: json });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][UPDATE STICKY BANNER]", err);
return R.error(res, "Could not update sticky banner setting.", 500);
}
};
@@ -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_Users = require('../../models/users/users.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { renderNotification } = require('../../services/notificationTemplate.service');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { adminExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes');
const R = require('../../utils/response.util');
@@ -439,10 +439,10 @@ exports.assignGroups = async (req, res) => {
if (userIds.length) {
const now = new Date();
const notify = await renderNotification({ type: 'task_assigned', data: {
const notify = NOTIFICATION_REGISTRY.task_assigned.build({
taskListName: taskList.name,
taskCount,
} });
});
await UserNotification.bulkCreate(
userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })),
{ validate: false }
@@ -763,11 +763,11 @@ exports.updateTask = async (req, res) => {
const now = new Date();
// Title/message are identical for every member — render once,
// then vary only the per-member groupId in the data payload.
const notify = await renderNotification({ type: 'task_requirements_updated', data: {
const notify = NOTIFICATION_REGISTRY.task_requirements_updated.build({
taskName: full.name,
taskListId: task.task_list_id,
groupId: null,
} });
});
await UserNotification.bulkCreate(
members.map(({ user_id, group_id }) => ({
user_id,
@@ -15,7 +15,7 @@ const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_c
const { Task } = require('../../models/task/task.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { renderNotification } = require('../../services/notificationTemplate.service');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { checkTaskCompletion, fireTaskCompletedEvent } = require('../client/task.controller');
const { adminExclude } = require('../../models/task/task_completion.attributes');
@@ -190,9 +190,9 @@ exports.reviewSubmission = async (req, res) => {
});
try {
const notify = await renderNotification({ type: 'task_submission_reviewed', data: {
const notify = NOTIFICATION_REGISTRY.task_submission_reviewed.build({
taskName: task.name, status, review_note: review_note || null,
} });
});
await UserNotification.create({ user_id: completion.user_id, ...notify, seen: false });
} catch (notifyErr) {
console.error('[ADMIN][REVIEW SUBMISSION][NOTIFY]', notifyErr);
+10 -13
View File
@@ -45,7 +45,7 @@ const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util')
const { onUserRegistered } = require('../services/achievements.service');
const AdminNotification = require('../models/notifications/admin_notification.mdl');
const UserNotification = require('../models/notifications/user_notification.mdl');
const { renderNotification } = require('../services/notificationTemplate.service');
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
const { sendEmail } = require('../services/email.service');
const buildSessionInfo = require('../utils/session_info.util');
const logActivity = require('../utils/logActivity.util');
@@ -171,19 +171,17 @@ exports.register = async (req, res) => {
// Fire-and-forget: notify admins — explicit group or NOGRP fallback
if (group) {
renderNotification({ type: 'user_registration', data: {
AdminNotification.create(NOTIFICATION_REGISTRY.user_registration.build({
groupName: group.name,
groupCode: group.group_code,
userEmail: email,
} })
.then(notify => AdminNotification.create(notify))
}))
.catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err));
} else if (enrollGroup) {
renderNotification({ type: 'nogrp_user_registered', data: {
AdminNotification.create(NOTIFICATION_REGISTRY.nogrp_user_registered.build({
userEmail: email,
regType: 'system',
} })
.then(notify => AdminNotification.create(notify))
}))
.catch(err => console.error('[AUTH] Failed to emit nogrp_user_registered notification:', err));
}
@@ -241,12 +239,12 @@ exports.verifyOTP = async (req, res) => {
const notifications = [
{
user_id: user.user_id,
...(await renderNotification({ type: 'welcome', data: {
...NOTIFICATION_REGISTRY.welcome.build({
groupName: grp?.name ?? null,
groupCode: grp?.group_code ?? null,
accType: user.acc_type,
groupId: membership?.group_id ?? null,
} })),
}),
createdAt: now,
updatedAt: now,
},
@@ -254,7 +252,7 @@ exports.verifyOTP = async (req, res) => {
if (grp?.group_code === 'NOGRP') {
notifications.push({
user_id: user.user_id,
...(await renderNotification({ type: 'nogrp_welcome', data: {} })),
...NOTIFICATION_REGISTRY.nogrp_welcome.build(),
createdAt: now,
updatedAt: now,
});
@@ -475,11 +473,10 @@ exports.googleCallback = async (req, res) => {
// Welcome email/achievements/welcome-notification are deferred to
// verifyOTP's first-time branch now (this account isn't verified yet —
// it still has to complete the same OTP gate as a system registration).
renderNotification({ type: 'nogrp_user_registered', data: {
AdminNotification.create(NOTIFICATION_REGISTRY.nogrp_user_registered.build({
userEmail: payload.email,
regType: 'google',
} })
.then(notify => AdminNotification.create(notify))
}))
.catch(err => console.error('[AUTH] googleCallback: Failed to emit nogrp_user_registered notification:', err));
} catch (err) {
await t.rollback();
@@ -189,6 +189,38 @@ exports.getActiveAdvertisements = async (req, res) => {
}
};
// ─── GET BY UUID ──────────────────────────────────────────────────────────────
//
// Resolves a single live advertisement by uuid for its own landing page — the
// destination CTA/banner clicks resolve to when the ad has no redirect_link
// (see /ads/:uuid on the client).
//
// GET /api/client/advertisements/uuid/:uuid
//
exports.getAdvertisementByUuid = async (req, res) => {
try {
const { uuid } = req.params;
if (!uuid) return R.error(res, "uuid is required.", 400);
const advertisement = await Advertisement.findOne({
where: liveWhere({ uuid }),
include: [AD_IMAGE_INCLUDE],
attributes: { exclude: AD_CLIENT_EXCLUDE },
});
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
const json = advertisement.toJSON();
json.status = deriveStatus(json);
if (json.image) await attachImageStreamToken(json.image, req);
return R.success(res, "Advertisement retrieved.", { data: json });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][GET BY UUID]", err);
return R.error(res, "Could not retrieve advertisement.", 500);
}
};
// ─── TRACK CLICK ──────────────────────────────────────────────────────────────
//
// POST /api/client/advertisements/:advertisementId/click
+33 -4
View File
@@ -40,7 +40,7 @@ const { onCourseCompleted } = require('../../services/achievements.service'
const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
const Certificate = require('../../models/courses/certificate.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { renderNotification } = require('../../services/notificationTemplate.service');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const notDeleted = { deletedAt: null };
@@ -468,6 +468,10 @@ exports.getUnit = async (req, res) => {
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
if (!link) return R.error(res, "Unit not found.", 404);
if (!await canAccessUnit(req.user.user_id, unitId)) {
return R.error(res, "You do not have access to this unit.", 403);
}
const unit = await Unit.findOne({
where: { unit_id: unitId, ...notDeleted },
attributes: [
@@ -519,6 +523,10 @@ exports.getLesson = async (req, res) => {
]);
if (!courseLink || !lessonLink) return R.error(res, "Lesson not found.", 404);
if (!await canAccessLesson(req.user.user_id, lessonId)) {
return R.error(res, "You do not have access to this lesson.", 403);
}
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, ...notDeleted },
attributes: [
@@ -557,6 +565,10 @@ exports.getUnitQuiz = async (req, res) => {
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
if (!link) return R.error(res, "Unit not found.", 404);
if (!await canAccessUnit(req.user.user_id, unitId)) {
return R.error(res, "You do not have access to this unit.", 403);
}
const quiz = await UnitQuiz.findOne({
where: { unit_id: unitId, ...notDeleted },
attributes: [
@@ -615,6 +627,10 @@ exports.getCourseAssessment = async (req, res) => {
try {
const { courseId } = req.params;
if (!await canAccessCourse(req.user.user_id, courseId)) {
return R.error(res, "You do not have access to this course.", 403);
}
const assessment = await CourseAssessment.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: [
@@ -698,6 +714,10 @@ exports.startCourseAssessment = async (req, res) => {
const { courseId, assessmentId } = req.params;
const user_id = req.user.user_id;
if (!await canAccessCourse(user_id, courseId)) {
return R.error(res, "You do not have access to this course.", 403);
}
const assessment = await CourseAssessment.findOne({
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
attributes: ["assessment_id", "time_limit_minutes", "passing_score", "max_attempts", "cooldown_hours"],
@@ -857,6 +877,10 @@ exports.submitUnitQuiz = async (req, res) => {
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
if (!link) return R.error(res, "Unit not found.", 404);
if (!await canAccessUnit(user_id, unitId)) {
return R.error(res, "You do not have access to this unit.", 403);
}
const quiz = await UnitQuiz.findOne({
where: { quiz_id: quizId, unit_id: unitId, ...notDeleted },
include: [{
@@ -949,6 +973,10 @@ exports.submitCourseAssessment = async (req, res) => {
const { answers = {}, session_id } = req.body;
const user_id = req.user.user_id;
if (!await canAccessCourse(user_id, courseId)) {
return R.error(res, "You do not have access to this course.", 403);
}
const assessment = await CourseAssessment.findOne({
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
include: [{
@@ -1038,9 +1066,10 @@ exports.submitCourseAssessment = async (req, res) => {
}
// Immediate notification: course completed, certificate incoming
renderNotification({ type: 'course_completed', data: { courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null } })
.then(notify => UserNotification.create({ user_id, ...notify }))
.catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err));
UserNotification.create({
user_id,
...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null }),
}).catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err));
}
return R.success(res, "Assessment submitted.", {
+57 -13
View File
@@ -12,7 +12,45 @@
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
const UserNotification = require('../../models/notifications/user_notification.mdl');
const StickyBannerSetting = require('../../models/notifications/sticky_banner_setting.mdl');
const mdl_Assets = require('../../models/assets/assets.mdl');
const mediaToken = require('../../services/mediaToken.service');
const R = require('../../utils/response.util');
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
const STICKY_LIMIT = 3;
const IMAGE_INCLUDE = {
model: mdl_Assets,
as: 'image',
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"],
required: false,
};
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken
// — kept duplicated rather than shared across the admin/client boundary.
async function attachImageStreamToken(image, req) {
if (!image || image.storage_provider !== 's3' || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
return image;
}
const ip = mediaToken.resolveIp(req);
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
image.stream_token = token;
image.file_url = null;
image.thumbnail_url = null;
delete image.storage_key;
return image;
}
// One shared banner image for the whole rotating sticky bar (see
// controllers/admin/notificationBroadcasts.controller.js's
// getStickyBannerSetting/updateStickyBannerSetting) — not per-announcement.
async function resolveSharedBannerImage(req) {
const setting = await StickyBannerSetting.findOne({ where: { id: 1 }, include: [IMAGE_INCLUDE] });
if (!setting?.image) return null;
const image = setting.toJSON().image;
await attachImageStreamToken(image, req);
return image;
}
// ─── GET /client/notifications ────────────────────────────────────────────────
async function list(req, res) {
@@ -23,7 +61,7 @@ async function list(req, res) {
const offset = (page - 1) * limit;
const { count, rows } = await UserNotification.findAndCountAll({
where: { user_id: userId, show_in_notifications: true },
where: { user_id: userId, show_in_notifications: true, ...notInFutureOrExpired() },
order: [['createdAt', 'DESC']],
limit,
offset,
@@ -44,7 +82,7 @@ async function unseenCount(req, res) {
if (!req.user) return R.success(res, 'Unseen count fetched.', { count: null });
try {
const count = await UserNotification.count({
where: { user_id: req.user.user_id, seen: false, show_in_notifications: true },
where: { user_id: req.user.user_id, seen: false, show_in_notifications: true, ...notInFutureOrExpired() },
});
return R.success(res, 'Unseen count fetched.', { count });
} catch (err) {
@@ -56,18 +94,24 @@ async function unseenCount(req, res) {
// ─── GET /client/notifications/sticky ─────────────────────────────────────
async function stickyAnnouncement(req, res) {
try {
const notification = await UserNotification.findOne({
where: {
user_id: req.user.user_id,
seen: false,
show_in_sticky: true,
type: "announcement",
},
order: [["createdAt", "DESC"]],
});
const [notifications, bannerImage] = await Promise.all([
UserNotification.findAll({
where: {
user_id: req.user.user_id,
seen: false,
show_in_sticky: true,
type: "announcement",
...notInFutureOrExpired(),
},
order: [["createdAt", "DESC"]],
limit: STICKY_LIMIT,
}),
resolveSharedBannerImage(req),
]);
return R.success(res, "Sticky announcement fetched.", {
announcement: notification,
return R.success(res, "Sticky announcements fetched.", {
announcements: notifications,
bannerImage,
});
} catch (err) {
console.error("[CLIENT NOTIFICATION] stickyAnnouncement error:", err);
+2 -2
View File
@@ -25,7 +25,7 @@ const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { renderNotification } = require('../../services/notificationTemplate.service');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { onTaskCompleted, onTaskListCompleted } = require('../../services/achievements.service');
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -248,7 +248,7 @@ const fireTaskCompletedEvent = async (userId, taskId) => {
if (!task) return;
try {
const notify = await renderNotification({ type: 'task_completed', data: { taskName: task.name } });
const notify = NOTIFICATION_REGISTRY.task_completed.build({ taskName: task.name });
await UserNotification.create({ user_id: userId, ...notify, seen: false });
} catch (notifyErr) {
console.error('[TASK][NOTIFY COMPLETED]', notifyErr);
+9 -8
View File
@@ -22,7 +22,7 @@ const { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl');
const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util');
const { renderNotification } = require('../../services/notificationTemplate.service');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
require('../../models/tiers/tier.associations');
@@ -49,13 +49,14 @@ exports.getMyTier = async (req, res) => {
// ── Inline safety net: expire between cron ticks ──────────────────────────
if (tier && tier.expires_at && new Date(tier.expires_at) <= new Date()) {
await tier.update({ status: 'expired' });
renderNotification({ type: 'tier_expired', data: {
tier: tier.tier,
label: tier.plan?.label ?? null,
planId: tier.plan?.plan_id ?? null,
} })
.then(notify => UserNotification.create({ user_id: req.user.user_id, ...notify }))
.catch(() => {});
UserNotification.create({
user_id: req.user.user_id,
...NOTIFICATION_REGISTRY.tier_expired.build({
tier: tier.tier,
label: tier.plan?.label ?? null,
planId: tier.plan?.plan_id ?? null,
}),
}).catch(() => {});
return R.success(res, 'Active tier retrieved.', {
tier: 'free', status: 'active', category: null, just_expired: true,
});