Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-12 12:39:17 +08:00
parent 82ea9c77c4
commit ea3e82e54c
47 changed files with 1301 additions and 481 deletions
@@ -4,16 +4,20 @@ const sequelize = require("../../config/db.config");
const NotificationBroadcast = require("../../models/notifications/notification_broadcast.mdl");
const AdminNotification = require("../../models/notifications/admin_notification.mdl");
const UserNotification = require("../../models/notifications/user_notification.mdl");
const StickyBannerSetting = require("../../models/notifications/sticky_banner_setting.mdl");
const mdl_Users = require('../../models/users/users.mdl');
const mdl_Assets = require('../../models/assets/assets.mdl');
const { TaskList } = require('../../models/task/task.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { Course } = require('../../models/courses/courses.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mediaToken = require("../../services/mediaToken.service");
const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/notifications/notification_broadcast.attributes");
const logActivity = require('../../utils/logActivity.util');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
const {
ALLOWED_TARGET_TYPES,
SCOPED_TARGET_TYPES,
@@ -28,10 +32,58 @@ const { Op } = require('sequelize');
const notDeleted = { deletedAt: null };
// Fields needed off the associated Asset to render the shared sticky banner
// preview AND (for S3 assets) mint a stream token — mirrors advertisements.controller.js.
const IMAGE_ATTRIBUTES = ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"];
const IMAGE_INCLUDE = { model: mdl_Assets, as: "image", attributes: IMAGE_ATTRIBUTES, required: false };
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken
// — kept duplicated rather than shared (same rationale used there).
async function attachImageStreamToken(image, req) {
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
return image;
}
const ip = mediaToken.resolveIp(req);
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
image.stream_token = token;
image.file_url = null;
image.thumbnail_url = null;
delete image.storage_key;
return image;
}
// "Active sticky" = live in the rotating sticky banner right now: sent,
// show_in_sticky, not archived, and within its own start/end window. Caps the
// bar at 3 concurrent slots (see sendBroadcast/updateBroadcast below).
async function countActiveSticky(excludeId = null) {
return NotificationBroadcast.count({
where: {
status: 'sent',
show_in_sticky: true,
...notDeleted,
...notInFutureOrExpired(),
...(excludeId ? { broadcast_id: { [Op.ne]: excludeId } } : {}),
},
});
}
const MAX_ACTIVE_STICKY = 3;
const ACTIVE_STICKY_CAP_MESSAGE = `Maximum of ${MAX_ACTIVE_STICKY} active sticky announcements right now — this stays in Draft until one ends or is archived.`;
async function applyBroadcastFields(broadcast, body) {
if (body.title !== undefined) broadcast.title = body.title;
if (body.message !== undefined) broadcast.message = body.message;
if (body.link_url !== undefined) broadcast.link_url = body.link_url?.trim() || null;
if (body.link_label !== undefined) broadcast.link_label = body.link_label?.trim() || null;
if (body.color !== undefined) broadcast.color = body.color || 'indigo';
if (body.start_date !== undefined) broadcast.start_date = body.start_date || null;
if (body.end_date !== undefined) broadcast.end_date = body.end_date || null;
if (broadcast.start_date && broadcast.end_date && new Date(broadcast.start_date) > new Date(broadcast.end_date)) {
const err = new Error("Start date must be before end date.");
err.status = 400;
throw err;
}
if (body.show_in_sticky !== undefined) broadcast.show_in_sticky = !!body.show_in_sticky;
if (body.show_in_notifications !== undefined) broadcast.show_in_notifications = !!body.show_in_notifications;
@@ -161,11 +213,15 @@ exports.createBroadcast = async (req, res) => {
title,
message,
link_url,
link_label,
color,
target_type,
target_id,
createdBy,
show_in_sticky,
show_in_notifications,
start_date,
end_date,
} = req.body;
if (!title) return R.error(res, "title is required.", 400);
@@ -181,6 +237,10 @@ exports.createBroadcast = async (req, res) => {
return R.error(res, "At least one of show_in_sticky or show_in_notifications must be enabled.", 400);
}
if (start_date && end_date && new Date(start_date) > new Date(end_date)) {
return R.error(res, "Start date must be before end date.", 400);
}
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
const t = await sequelize.transaction();
@@ -189,6 +249,10 @@ exports.createBroadcast = async (req, res) => {
title,
message,
link_url: link_url?.trim() || null,
link_label: link_label?.trim() || null,
color: color || 'indigo',
start_date: start_date || null,
end_date: end_date || null,
createdBy,
status: 'draft',
target_type,
@@ -222,7 +286,7 @@ exports.updateBroadcast = async (req, res) => {
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be edited.", 400);
const wasActiveSticky = broadcast.status === 'sent' && broadcast.show_in_sticky;
const t = await sequelize.transaction();
try {
@@ -232,8 +296,54 @@ exports.updateBroadcast = async (req, res) => {
err.status = 400;
throw err;
}
// Editing a live broadcast to newly flip on show_in_sticky is the same
// "activate a sticky slot" action as sendBroadcast — must respect the
// same 3-slot cap, or it's a trivial bypass.
if (broadcast.status === 'sent' && broadcast.show_in_sticky && !wasActiveSticky) {
if ((await countActiveSticky(broadcast.broadcast_id)) >= MAX_ACTIVE_STICKY) {
const err = new Error(ACTIVE_STICKY_CAP_MESSAGE);
err.status = 409;
throw err;
}
}
broadcast.updatedBy = req.body.updatedBy ?? null;
await broadcast.save({ transaction: t });
// Already-sent broadcasts have per-recipient rows created at send time
// (see sendBroadcast) — propagate content/display edits into them so
// changes show up immediately for anyone currently seeing it. Target/
// audience fields are deliberately NOT propagated (see plan notes):
// recipients were already resolved, and task_list's per-user groupId
// deep-link (stored in each row's own `data`) must not be clobbered.
if (broadcast.status === 'sent') {
const propagated = {
title: broadcast.title,
message: broadcast.message,
color: broadcast.color,
show_in_sticky: broadcast.show_in_sticky,
show_in_notifications: broadcast.show_in_notifications,
start_date: broadcast.start_date,
end_date: broadcast.end_date,
linkUrl: broadcast.link_url,
linkLabel: broadcast.link_label,
broadcastId: broadcast.broadcast_id,
};
for (const table of ['admin_notifications', 'user_notifications']) {
await sequelize.query(
`UPDATE ${table}
SET title = :title, message = :message, color = :color,
show_in_sticky = :show_in_sticky, show_in_notifications = :show_in_notifications,
start_date = :start_date, end_date = :end_date,
data = data || jsonb_build_object('linkUrl', :linkUrl, 'linkLabel', :linkLabel)
WHERE broadcast_id = :broadcastId`,
{ replacements: propagated, transaction: t }
);
}
}
await t.commit();
logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
@@ -260,6 +370,10 @@ exports.sendBroadcast = async (req, res) => {
if (!broadcast) return R.error(res, "Announcement not found.", 404);
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be sent.", 400);
if (broadcast.show_in_sticky && (await countActiveSticky(broadcast.broadcast_id)) >= MAX_ACTIVE_STICKY) {
return R.error(res, ACTIVE_STICKY_CAP_MESSAGE, 409);
}
const t = await sequelize.transaction();
try {
const now = new Date();
@@ -275,12 +389,13 @@ exports.sendBroadcast = async (req, res) => {
message: broadcast.message,
targetType,
targetId,
linkUrl: broadcast.link_url,
linkUrl: broadcast.link_url,
linkLabel: broadcast.link_label,
});
if (targetType === 'admin' || targetType === 'both') {
await AdminNotification.create(
{ ...baseNotify, seen: false, show_in_sticky: showInSticky, show_in_notifications: showInNotifications },
{ ...baseNotify, seen: false, show_in_sticky: showInSticky, show_in_notifications: showInNotifications, color: broadcast.color, start_date: broadcast.start_date, end_date: broadcast.end_date, broadcast_id: broadcast.broadcast_id },
{ transaction: t }
);
recipientCount += 1;
@@ -312,7 +427,8 @@ exports.sendBroadcast = async (req, res) => {
? NOTIFICATION_REGISTRY.broadcast.build({
title: broadcast.title, message: broadcast.message, targetType, targetId,
groupId: groupByUser[user_id] ?? null,
linkUrl: broadcast.link_url,
linkUrl: broadcast.link_url,
linkLabel: broadcast.link_label,
})
: baseNotify),
seen: false,
@@ -320,6 +436,10 @@ exports.sendBroadcast = async (req, res) => {
updatedAt: now,
show_in_sticky: showInSticky,
show_in_notifications: showInNotifications,
color: broadcast.color,
start_date: broadcast.start_date,
end_date: broadcast.end_date,
broadcast_id: broadcast.broadcast_id,
})),
{ validate: false, transaction: t }
);
@@ -505,3 +625,62 @@ exports.permanentlyDeleteBroadcasts = async (req, res) => {
return R.error(res, "Could not permanently delete announcements.", 500);
}
};
// ─── STICKY BANNER (shared, singleton) ────────────────────────────────────────
// One image for the whole rotating sticky bar (up to 3 concurrent
// announcements share it) — not one per announcement. Set from the
// Announcements list page.
exports.getStickyBannerSetting = async (req, res) => {
try {
const setting = await StickyBannerSetting.findOne({ where: { id: 1 }, include: [IMAGE_INCLUDE] });
if (!setting) return R.success(res, "Sticky banner setting retrieved.", { data: null });
const json = setting.toJSON();
if (json.image) await attachImageStreamToken(json.image, req);
return R.success(res, "Sticky banner setting retrieved.", { data: json });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET STICKY BANNER]", err);
return R.error(res, "Could not retrieve sticky banner setting.", 500);
}
};
exports.updateStickyBannerSetting = async (req, res) => {
try {
const { image_asset_id, updatedBy } = req.body;
let validatedImageAssetId = null;
if (image_asset_id) {
const asset = await mdl_Assets.findOne({ where: { asset_id: image_asset_id, deletedAt: null } });
if (!asset) return R.error(res, "Selected image asset was not found.", 400);
validatedImageAssetId = asset.asset_id;
}
// Plain find-then-create/update rather than findOrCreate() — Sequelize's
// postgres findOrCreate() relies on a temp PL/pgSQL function for atomicity
// that CockroachDB doesn't support ("cannot create user-defined functions
// under a temporary schema") — same fix as trustedDevice.service.js.
let setting = await StickyBannerSetting.findOne({ where: { id: 1 } });
if (setting) {
setting.image_asset_id = validatedImageAssetId;
setting.updatedBy = updatedBy ?? null;
await setting.save();
} else {
setting = await StickyBannerSetting.create({ id: 1, image_asset_id: validatedImageAssetId, updatedBy: updatedBy ?? null });
}
// Reload with the image association so the response carries a fully
// resolved preview (stream token for S3) — same shape as the GET, so the
// frontend never needs to locally guess/merge in an optimistic image.
await setting.reload({ include: [IMAGE_INCLUDE] });
const json = setting.toJSON();
if (json.image) await attachImageStreamToken(json.image, req);
logActivity(req.user?.user_id, 'update_sticky_banner_setting', { entityType: 'sticky_banner_setting', entityId: 1 });
return R.success(res, "Sticky banner setting updated.", { data: json });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][UPDATE STICKY BANNER]", err);
return R.error(res, "Could not update sticky banner setting.", 500);
}
};