mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user