mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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);
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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 } } },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }) => ({
|
||||||
|
|||||||
@@ -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([
|
||||||
|
AdminNotification.findAll({
|
||||||
where: {
|
where: {
|
||||||
seen: false,
|
seen: false,
|
||||||
show_in_sticky: true,
|
show_in_sticky: true,
|
||||||
type: 'announcement',
|
type: 'announcement',
|
||||||
|
...notInFutureOrExpired(),
|
||||||
},
|
},
|
||||||
order: [['createdAt', 'DESC']],
|
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();
|
||||||
@@ -276,11 +390,12 @@ exports.sendBroadcast = async (req, res) => {
|
|||||||
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;
|
||||||
@@ -313,6 +428,7 @@ exports.sendBroadcast = async (req, res) => {
|
|||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.", {
|
||||||
|
|||||||
@@ -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([
|
||||||
|
UserNotification.findAll({
|
||||||
where: {
|
where: {
|
||||||
user_id: req.user.user_id,
|
user_id: req.user.user_id,
|
||||||
seen: false,
|
seen: false,
|
||||||
show_in_sticky: true,
|
show_in_sticky: true,
|
||||||
type: "announcement",
|
type: "announcement",
|
||||||
|
...notInFutureOrExpired(),
|
||||||
},
|
},
|
||||||
order: [["createdAt", "DESC"]],
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
user_id: req.user.user_id,
|
||||||
|
...NOTIFICATION_REGISTRY.tier_expired.build({
|
||||||
tier: tier.tier,
|
tier: tier.tier,
|
||||||
label: tier.plan?.label ?? null,
|
label: tier.plan?.label ?? null,
|
||||||
planId: tier.plan?.plan_id ?? null,
|
planId: tier.plan?.plan_id ?? null,
|
||||||
} })
|
}),
|
||||||
.then(notify => UserNotification.create({ user_id: req.user.user_id, ...notify }))
|
}).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,
|
||||||
});
|
});
|
||||||
|
|||||||
+26
-8
@@ -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 cron = require('node-cron');
|
||||||
const userNotifications = require('./jobs/user_notifications.cron');
|
const userNotifications = require('./jobs/user_notifications.cron');
|
||||||
const issueCertificates = require('./jobs/issue_certificates.cron');
|
const issueCertificates = require('./jobs/issue_certificates.cron');
|
||||||
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
|
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
|
||||||
const taskDueSoon = require('./jobs/task_due_soon.cron');
|
const taskDueSoon = require('./jobs/task_due_soon.cron');
|
||||||
|
const 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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
},
|
||||||
|
};
|
||||||
+55
@@ -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]));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user