mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
469 lines
22 KiB
JavaScript
469 lines
22 KiB
JavaScript
// 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);
|
|
}
|
|
};
|