Files
starr-philproperties/controllers/admin/notificationBroadcasts.controller.js
T
kennethobsequio 3c13bb9821 add: more things
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-04 03:35:09 +08:00

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);
}
};