// controllers/admin/notificationBroadcasts.controller.js 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 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, validateTargetId, resolveTaskListUserGroups, resolveTargetUserIds, } = require('../../utils/audienceResolver.util'); const { Op } = require('sequelize'); // ─── Helpers ────────────────────────────────────────────────────────────────── 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 = 2; const ACTIVE_STICKY_CAP_MESSAGE = `Maximum of ${MAX_ACTIVE_STICKY} active sticky alerts right now — this stays in Draft until one ends or is archived.`; async function validateImageAssetId(image_asset_id) { if (!image_asset_id) return null; const asset = await mdl_Assets.findOne({ where: { asset_id: image_asset_id, deletedAt: null } }); if (!asset) { const err = new Error("Selected image asset was not found."); err.status = 400; throw err; } return asset.asset_id; } // Pushes a visibility change into the already-fanned-out per-recipient rows // (admin_notifications/user_notifications) so archive/restore take effect // immediately for anyone currently seeing the alert — same rationale as the // content/display propagation in updateBroadcast below, just for the two // visibility flags. `where` is a raw SQL fragment + its replacements so this // can target either a single broadcast_id or an IN-list. async function propagateNotificationVisibility(where, { show_in_sticky, show_in_notifications }, transaction) { for (const table of ['admin_notifications', 'user_notifications']) { await sequelize.query( `UPDATE ${table} SET show_in_sticky = :show_in_sticky, show_in_notifications = :show_in_notifications WHERE ${where.sql}`, { replacements: { show_in_sticky, show_in_notifications, ...where.replacements }, transaction } ); } } async function applyBroadcastFields(broadcast, body) { if (body.title !== undefined) broadcast.title = body.title; if (body.message !== undefined) broadcast.message = body.message || 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.image_asset_id !== undefined) broadcast.image_asset_id = await validateImageAssetId(body.image_asset_id); 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; if (body.target_type !== undefined) { if (!ALLOWED_TARGET_TYPES.includes(body.target_type)) { const err = new Error(`Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`); err.status = 400; throw err; } if (SCOPED_TARGET_TYPES.includes(body.target_type)) { if (!body.target_id) { const err = new Error("target_id is required for this target_type."); err.status = 400; throw err; } await validateTargetId(body.target_type, body.target_id); broadcast.target_id = String(body.target_id); } else { broadcast.target_id = null; } broadcast.target_type = body.target_type; } } // ─── Target resolution ──────────────────────────────────────────────────────── // task_list/course/tier_plan resolution now lives in utils/audienceResolver.util.js // (resolveTargetUserIds, resolveTaskListUserGroups — imported above) so email // broadcasts resolve the same targets identically. // Enrich one or many broadcast rows with a human-readable target_label. async function attachTargetLabels(rows) { const list = Array.isArray(rows) ? rows : [rows]; const idsByType = { task_list: [], course: [], tier_plan: [] }; list.forEach((r) => { if (SCOPED_TARGET_TYPES.includes(r.target_type) && r.target_id) idsByType[r.target_type].push(r.target_id); }); const [taskLists, courses, plans] = await Promise.all([ idsByType.task_list.length ? TaskList.findAll({ where: { task_list_id: { [Op.in]: idsByType.task_list } }, attributes: ['task_list_id', 'name'], paranoid: false }) : [], idsByType.course.length ? Course.findAll({ where: { uuid: { [Op.in]: idsByType.course } }, attributes: ['uuid', 'title'], paranoid: false }) : [], idsByType.tier_plan.length ? mdl_TierPlans.findAll({ where: { plan_id: { [Op.in]: idsByType.tier_plan } }, attributes: ['plan_id', 'label'], paranoid: false }) : [], ]); const taskListMap = Object.fromEntries(taskLists.map((t) => [t.task_list_id, t.name])); const courseMap = Object.fromEntries(courses.map((c) => [c.uuid, c.title])); const planMap = Object.fromEntries(plans.map((p) => [String(p.plan_id), p.label])); list.forEach((r) => { if (r.target_type === 'task_list') r.target_label = taskListMap[r.target_id] ?? null; else if (r.target_type === 'course') r.target_label = courseMap[r.target_id] ?? null; else if (r.target_type === 'tier_plan') r.target_label = planMap[r.target_id] ?? null; else r.target_label = null; }); return rows; } // ─── GET ALL ────────────────────────────────────────────────────────────────── exports.getBroadcasts = async (req, res) => { try { const result = await paginate(NotificationBroadcast, req, { excludeAttributes: adminExclude, jsonbSchemas, computedAttributes, context: "list", auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' }, findOptions: { where: { ...notDeleted } }, }); if (Array.isArray(result?.data)) await attachTargetLabels(result.data); return R.success(res, "Alerts retrieved.", result); } catch (err) { console.error("[NOTIFICATION BROADCAST][GET ALL]", err); return R.error(res, "Could not retrieve alerts.", 500); } }; // ─── GET ONE ────────────────────────────────────────────────────────────────── exports.getBroadcast = async (req, res) => { try { const { broadcastId } = req.params; if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400); const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted }, include: [ { model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" }, { model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" }, IMAGE_INCLUDE, ], }); if (!broadcast) return R.error(res, "Alert not found.", 404); const json = broadcast.toJSON(); if (json.image) await attachImageStreamToken(json.image, req); if (json.creator) { json.creator = { user_id: json.creator.user_id, full_name: json.creator.personal_info?.name?.full_name ?? null, }; } if (json.updater) { json.updater = { user_id: json.updater.user_id, full_name: json.updater.personal_info?.name?.full_name ?? null, }; } await attachTargetLabels(json); return R.success(res, "Alert retrieved.", { data: json }); } catch (err) { console.error("[NOTIFICATION BROADCAST][GET ONE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── CREATE ─────────────────────────────────────────────────────────────────── exports.createBroadcast = async (req, res) => { try { const { title, message, link_url, link_label, color, image_asset_id, 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); if (!target_type) return R.error(res, "target_type is required.", 400); if (!ALLOWED_TARGET_TYPES.includes(target_type)) return R.error(res, `Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`, 400); if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, "target_id is required for this target_type.", 400); if (!createdBy) return R.error(res, "createdBy is required.", 400); const showSticky = show_in_sticky ?? false; const showNotifs = show_in_notifications ?? true; if (!showSticky && !showNotifs) { return R.error(res, "At least one of show_in_sticky or show_in_notifications must be enabled.", 400); } if (showSticky && showNotifs) { return R.error(res, "Choose only one: Sticky or Notifications.", 400); } if (showNotifs && !message) { return R.error(res, "message is required for Notifications alerts.", 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 validatedImageAssetId = await validateImageAssetId(image_asset_id); const t = await sequelize.transaction(); try { const broadcast = await NotificationBroadcast.build({ title, message: message || null, link_url: link_url?.trim() || null, link_label: link_label?.trim() || null, color: color || 'indigo', image_asset_id: validatedImageAssetId, start_date: start_date || null, end_date: end_date || null, createdBy, status: 'draft', target_type, target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null, show_in_sticky: showSticky, show_in_notifications: showNotifs, }); await broadcast.save({ transaction: t }); await t.commit(); logActivity(req.user?.user_id, 'create_notification_broadcast', { entityType: 'notification_broadcast', entityId: broadcast.broadcast_id, details: { target_type } }); return R.success(res, "Alert created.", { data: broadcast }, 201); } catch (dbErr) { try { await t.rollback(); } catch { /* connection gone */ } throw dbErr; } } catch (err) { console.error("[NOTIFICATION BROADCAST][CREATE]", err); if (err.status) return R.error(res, err.message, err.status); return R.error(res, "Internal server error.", 500); } }; // ─── UPDATE ─────────────────────────────────────────────────────────────────── exports.updateBroadcast = async (req, res) => { try { const { broadcastId } = req.params; if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400); const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } }); if (!broadcast) return R.error(res, "Notification broadcast not found.", 404); const wasActiveSticky = broadcast.status === 'sent' && broadcast.show_in_sticky; const t = await sequelize.transaction(); try { await applyBroadcastFields(broadcast, req.body); if (!broadcast.show_in_sticky && !broadcast.show_in_notifications) { const err = new Error("At least one of show_in_sticky or show_in_notifications must be enabled."); err.status = 400; throw err; } if (broadcast.show_in_sticky && broadcast.show_in_notifications) { const err = new Error("Choose only one: Sticky or Notifications."); err.status = 400; throw err; } if (broadcast.show_in_notifications && !broadcast.message) { const err = new Error("message is required for Notifications alerts."); 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, // admin_notifications/user_notifications.message stays NOT NULL — // sticky-mode broadcasts have a null message here, so fall back to "". message: broadcast.message || "", color: broadcast.color, image_asset_id: broadcast.image_asset_id, 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, image_asset_id = :image_asset_id, 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) }); return R.success(res, "Alert updated.", { data: broadcast }); } catch (dbErr) { try { await t.rollback(); } catch { /* gone */ } throw dbErr; } } catch (err) { console.error("[NOTIFICATION BROADCAST][UPDATE]", err); if (err.status) return R.error(res, err.message, err.status); return R.error(res, "Internal server error.", 500); } }; // ─── SEND ───────────────────────────────────────────────────────────────────── exports.sendBroadcast = async (req, res) => { try { const { broadcastId } = req.params; if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400); const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } }); if (!broadcast) return R.error(res, "Alert 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(); let recipientCount = 0; const targetType = broadcast.target_type; const targetId = broadcast.target_id; const showInSticky = !!broadcast.show_in_sticky; const showInNotifications = !!broadcast.show_in_notifications; const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({ title: broadcast.title, message: broadcast.message || "", targetType, targetId, 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, color: broadcast.color, image_asset_id: broadcast.image_asset_id, start_date: broadcast.start_date, end_date: broadcast.end_date, broadcast_id: broadcast.broadcast_id }, { transaction: t } ); recipientCount += 1; } let userIds = []; let groupByUser = {}; // only populated for task_list — one group_id per user, for deep-linking if (targetType === 'user' || targetType === 'both') { const users = await mdl_Users.findAll({ attributes: ['user_id'], where: { acc_type: 'user', deletedAt: null }, raw: true, transaction: t, }); userIds = users.map((u) => String(u.user_id)); } else if (targetType === 'task_list') { groupByUser = await resolveTaskListUserGroups(targetId); userIds = Object.keys(groupByUser); } else if (SCOPED_TARGET_TYPES.includes(targetType)) { userIds = await resolveTargetUserIds(targetType, targetId); } if (userIds.length) { await UserNotification.bulkCreate( userIds.map((user_id) => ({ user_id, ...(targetType === 'task_list' ? NOTIFICATION_REGISTRY.broadcast.build({ title: broadcast.title, message: broadcast.message || "", targetType, targetId, groupId: groupByUser[user_id] ?? null, linkUrl: broadcast.link_url, linkLabel: broadcast.link_label, }) : baseNotify), seen: false, createdAt: now, updatedAt: now, show_in_sticky: showInSticky, show_in_notifications: showInNotifications, color: broadcast.color, image_asset_id: broadcast.image_asset_id, start_date: broadcast.start_date, end_date: broadcast.end_date, broadcast_id: broadcast.broadcast_id, })), { validate: false, transaction: t } ); } recipientCount += userIds.length; broadcast.status = 'sent'; broadcast.sent_at = now; broadcast.recipient_count = recipientCount; await broadcast.save({ transaction: t }); await t.commit(); logActivity(req.user?.user_id, 'send_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId), details: { target_type: targetType, target_id: broadcast.target_id, recipient_count: recipientCount } }); return R.success(res, "Alert sent.", { data: broadcast }); } catch (dbErr) { try { await t.rollback(); } catch { /* gone */ } throw dbErr; } } catch (err) { console.error("[NOTIFICATION BROADCAST][SEND]", err); if (err.status) return R.error(res, err.message, err.status); return R.error(res, "Internal server error.", 500); } }; // ─── ARCHIVE (single) ───────────────────────────────────────────────────────── exports.archiveBroadcast = async (req, res) => { try { const { broadcastId } = req.params; if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400); const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } }); if (!broadcast) return R.error(res, "Alert not found.", 404); const t = await sequelize.transaction(); try { await broadcast.update({ deletedBy: req.body.deletedBy ?? null }, { transaction: t }); await broadcast.destroy({ transaction: t }); await propagateNotificationVisibility( { sql: 'broadcast_id = :broadcastId', replacements: { broadcastId: broadcast.broadcast_id } }, { show_in_sticky: false, show_in_notifications: false }, t ); await t.commit(); } catch (dbErr) { try { await t.rollback(); } catch { /* gone */ } throw dbErr; } logActivity(req.user?.user_id, 'archive_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); return R.success(res, "Alert archived."); } catch (err) { console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── ARCHIVE (bulk) ─────────────────────────────────────────────────────────── exports.archiveBroadcasts = async (req, res) => { try { const { ids, deletedBy } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400); const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids }, ...notDeleted } }); if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404); const activeIds = broadcasts.map((b) => b.broadcast_id); const t = await sequelize.transaction(); try { await NotificationBroadcast.update({ deletedBy: deletedBy ?? null }, { where: { broadcast_id: { [Op.in]: activeIds } }, transaction: t }); await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: activeIds } }, transaction: t }); await propagateNotificationVisibility( { sql: 'broadcast_id IN (:activeIds)', replacements: { activeIds } }, { show_in_sticky: false, show_in_notifications: false }, t ); await t.commit(); } catch (dbErr) { try { await t.rollback(); } catch { /* gone */ } throw dbErr; } logActivity(req.user?.user_id, 'bulk_archive_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: activeIds, count: activeIds.length } }); return R.success(res, `${activeIds.length} notification broadcast(s) archived.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); } catch (err) { console.error("[NOTIFICATION BROADCAST][BULK ARCHIVE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── RESTORE (single) ───────────────────────────────────────────────────────── exports.restoreBroadcast = async (req, res) => { try { const { broadcastId } = req.params; const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false }); if (!broadcast) return R.error(res, "Alert not found.", 404); if (!broadcast.deletedAt) return R.error(res, "Alert is not archived.", 400); const t = await sequelize.transaction(); try { await broadcast.restore({ transaction: t }); await broadcast.update({ deletedBy: null }, { transaction: t }); // Drafts never had per-recipient rows created — only propagate for // broadcasts that were actually sent. if (broadcast.status === 'sent') { await propagateNotificationVisibility( { sql: 'broadcast_id = :broadcastId', replacements: { broadcastId: broadcast.broadcast_id } }, { show_in_sticky: broadcast.show_in_sticky, show_in_notifications: broadcast.show_in_notifications }, t ); } await t.commit(); } catch (dbErr) { try { await t.rollback(); } catch { /* gone */ } throw dbErr; } logActivity(req.user?.user_id, 'restore_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); return R.success(res, "Alert restored.", { data: broadcast }); } catch (err) { console.error("[NOTIFICATION BROADCAST][RESTORE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── RESTORE (bulk) ─────────────────────────────────────────────────────────── exports.restoreBroadcasts = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400); const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids } }, paranoid: false }); if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404); const archived = broadcasts.filter((b) => b.deletedAt); if (!archived.length) return R.error(res, "All selected notification broadcasts are already active.", 400); const archivedIds = archived.map((b) => b.broadcast_id); const t = await sequelize.transaction(); try { await NotificationBroadcast.restore({ where: { broadcast_id: { [Op.in]: archivedIds } }, transaction: t }); await NotificationBroadcast.update({ deletedBy: null }, { where: { broadcast_id: { [Op.in]: archivedIds } }, paranoid: false, transaction: t }); // Visibility can differ per broadcast, so this can't be a single flat // UPDATE like the archive side — loop and restore each one's own // show_in_sticky/show_in_notifications values. for (const b of archived) { if (b.status !== 'sent') continue; await propagateNotificationVisibility( { sql: 'broadcast_id = :broadcastId', replacements: { broadcastId: b.broadcast_id } }, { show_in_sticky: b.show_in_sticky, show_in_notifications: b.show_in_notifications }, t ); } await t.commit(); } catch (dbErr) { try { await t.rollback(); } catch { /* gone */ } throw dbErr; } logActivity(req.user?.user_id, 'bulk_restore_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: archivedIds, count: archivedIds.length } }); return R.success(res, `${archivedIds.length} notification broadcast(s) restored.`, { restored_ids: archivedIds, skipped_ids: ids.filter((id) => !archivedIds.includes(id)), }); } catch (err) { console.error("[NOTIFICATION BROADCAST][BULK RESTORE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── ARCHIVED LIST ──────────────────────────────────────────────────────────── exports.getArchivedBroadcasts = async (req, res) => { try { const result = await paginate(NotificationBroadcast, req, { excludeAttributes: adminExclude, jsonbSchemas, computedAttributes, context: "list", auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' }, findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } }, }); return R.success(res, "Archived alerts retrieved.", result); } catch (err) { console.error("[NOTIFICATION BROADCAST][GET ARCHIVED]", err); return R.error(res, "Could not retrieve archived alerts.", 500); } }; // ─── PERMANENT DELETE (single) ──────────────────────────────────────────────── exports.permanentlyDeleteBroadcast = async (req, res) => { try { const { broadcastId } = req.params; const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false }); if (!broadcast) return R.error(res, "Alert not found.", 404); if (!broadcast.deletedAt) return R.error(res, "Alert must be archived before it can be permanently deleted.", 400); await broadcast.destroy({ force: true }); logActivity(req.user?.user_id, 'permanently_delete_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); return R.success(res, "Alert permanently deleted."); } catch (err) { console.error("[NOTIFICATION BROADCAST][PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete alert.", 500); } }; // ─── PERMANENT DELETE (bulk) ─────────────────────────────────────────────────── exports.permanentlyDeleteBroadcasts = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400); const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids } }, paranoid: false }); if (!broadcasts.length) return R.error(res, "No alerts found.", 404); const archived = broadcasts.filter((b) => b.deletedAt); if (!archived.length) return R.error(res, "All selected alerts must be archived before they can be permanently deleted.", 400); const archivedIds = archived.map((b) => b.broadcast_id); await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: archivedIds } }, force: true }); logActivity(req.user?.user_id, 'bulk_permanently_delete_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: archivedIds, count: archivedIds.length } }); return R.success(res, `${archivedIds.length} alert(s) permanently deleted.`, { deleted_ids: archivedIds, skipped_ids: ids.filter((id) => !archivedIds.includes(id)), }); } catch (err) { console.error("[NOTIFICATION BROADCAST][BULK PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete alerts.", 500); } };