mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,740 @@
|
||||
// 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 file 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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user