// 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 { 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 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 { ALLOWED_TARGET_TYPES, SCOPED_TARGET_TYPES, validateTargetId, resolveTaskListUserGroups, resolveTargetUserIds, } = require('../../utils/audienceResolver.util'); const { Op } = require('sequelize'); // ─── Helpers ────────────────────────────────────────────────────────────────── const notDeleted = { deletedAt: null }; async function applyBroadcastFields(broadcast, body) { if (body.title !== undefined) broadcast.title = body.title; if (body.message !== undefined) broadcast.message = body.message; 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, "Notification broadcasts retrieved.", result); } catch (err) { console.error("[NOTIFICATION BROADCAST][GET ALL]", err); return R.error(res, "Could not retrieve notification broadcasts.", 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" }, ], }); if (!broadcast) return R.error(res, "Notification broadcast not found.", 404); const json = broadcast.toJSON(); 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, "Notification broadcast 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, target_type, target_id, createdBy } = req.body; if (!title) return R.error(res, "title is required.", 400); if (!message) return R.error(res, "message 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); if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id); const t = await sequelize.transaction(); try { const broadcast = await NotificationBroadcast.build({ title, message, createdBy, status: 'draft', target_type, target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null, }); 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, "Notification broadcast 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); if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be edited.", 400); const t = await sequelize.transaction(); try { await applyBroadcastFields(broadcast, req.body); broadcast.updatedBy = req.body.updatedBy ?? null; await broadcast.save({ transaction: t }); await t.commit(); logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); return R.success(res, "Notification broadcast 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, "Notification broadcast not found.", 404); if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be sent.", 400); const t = await sequelize.transaction(); try { const now = new Date(); let recipientCount = 0; const targetType = broadcast.target_type; const targetId = broadcast.target_id; const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({ title: broadcast.title, message: broadcast.message, targetType, targetId, }); if (targetType === 'admin' || targetType === 'both') { await AdminNotification.create({ ...baseNotify, seen: false }, { 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, }) : baseNotify), seen: false, createdAt: now, updatedAt: now, })), { 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, "Notification broadcast 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, "Notification broadcast not found.", 404); await broadcast.update({ deletedBy: req.body.deletedBy ?? null }); await broadcast.destroy(); logActivity(req.user?.user_id, 'archive_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); return R.success(res, "Notification broadcast 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); await NotificationBroadcast.update({ deletedBy: deletedBy ?? null }, { where: { broadcast_id: { [Op.in]: activeIds } } }); await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: activeIds } } }); 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, "Notification broadcast not found.", 404); if (!broadcast.deletedAt) return R.error(res, "Notification broadcast is not archived.", 400); await broadcast.restore(); await broadcast.update({ deletedBy: null }); logActivity(req.user?.user_id, 'restore_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); return R.success(res, "Notification broadcast 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); await NotificationBroadcast.restore({ where: { broadcast_id: { [Op.in]: archivedIds } } }); await NotificationBroadcast.update({ deletedBy: null }, { where: { broadcast_id: { [Op.in]: archivedIds } }, paranoid: false }); 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 notification broadcasts retrieved.", result); } catch (err) { console.error("[NOTIFICATION BROADCAST][GET ARCHIVED]", err); return R.error(res, "Could not retrieve archived notification broadcasts.", 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, "Notification broadcast not found.", 404); if (!broadcast.deletedAt) return R.error(res, "Notification broadcast 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, "Notification broadcast permanently deleted."); } catch (err) { console.error("[NOTIFICATION BROADCAST][PERMANENT DELETE]", err); return R.error(res, "Could not permanently delete notification broadcast.", 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 notification broadcasts found.", 404); const archived = broadcasts.filter((b) => b.deletedAt); if (!archived.length) return R.error(res, "All selected notification broadcasts 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} notification broadcast(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 notification broadcasts.", 500); } };