added and fix some of things

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-08-03 12:03:02 +08:00
parent 6765c1faba
commit d246c09cdd
31 changed files with 724 additions and 510 deletions
@@ -4,7 +4,6 @@ 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 StickyBannerSetting = require("../../models/notifications/sticky_banner_setting.mdl");
const mdl_Users = require('../../models/users/users.mdl');
const mdl_Assets = require('../../models/assets/assets.mdl');
const { TaskList } = require('../../models/task/task.mdl');
@@ -68,7 +67,33 @@ async function countActiveSticky(excludeId = null) {
}
const MAX_ACTIVE_STICKY = 3;
const ACTIVE_STICKY_CAP_MESSAGE = `Maximum of ${MAX_ACTIVE_STICKY} active sticky announcements right now — this stays in Draft until one ends or is archived.`;
const ACTIVE_STICKY_CAP_MESSAGE = `Maximum of ${MAX_ACTIVE_STICKY} active sticky alerts right now — this stays in Draft until one ends or is archived.`;
async function validateImageAssetId(image_asset_id) {
if (!image_asset_id) return null;
const asset = await mdl_Assets.findOne({ where: { asset_id: image_asset_id, deletedAt: null } });
if (!asset) {
const err = new Error("Selected image asset was not found.");
err.status = 400;
throw err;
}
return asset.asset_id;
}
// Pushes a visibility change into the already-fanned-out per-recipient rows
// (admin_notifications/user_notifications) so archive/restore take effect
// immediately for anyone currently seeing the alert — same rationale as the
// content/display propagation in updateBroadcast below, just for the two
// visibility flags. `where` is a raw SQL fragment + its replacements so this
// can target either a single broadcast_id or an IN-list.
async function propagateNotificationVisibility(where, { show_in_sticky, show_in_notifications }, transaction) {
for (const table of ['admin_notifications', 'user_notifications']) {
await sequelize.query(
`UPDATE ${table} SET show_in_sticky = :show_in_sticky, show_in_notifications = :show_in_notifications WHERE ${where.sql}`,
{ replacements: { show_in_sticky, show_in_notifications, ...where.replacements }, transaction }
);
}
}
async function applyBroadcastFields(broadcast, body) {
if (body.title !== undefined) broadcast.title = body.title;
@@ -76,6 +101,7 @@ async function applyBroadcastFields(broadcast, body) {
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;
@@ -157,10 +183,10 @@ exports.getBroadcasts = async (req, res) => {
if (Array.isArray(result?.data)) await attachTargetLabels(result.data);
return R.success(res, "Announcements retrieved.", result);
return R.success(res, "Alerts retrieved.", result);
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ALL]", err);
return R.error(res, "Could not retrieve announcements.", 500);
return R.error(res, "Could not retrieve alerts.", 500);
}
};
@@ -176,12 +202,14 @@ exports.getBroadcast = async (req, res) => {
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, "Announcement not found.", 404);
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 = {
@@ -198,7 +226,7 @@ exports.getBroadcast = async (req, res) => {
await attachTargetLabels(json);
return R.success(res, "Announcement retrieved.", { data: 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);
@@ -215,6 +243,7 @@ exports.createBroadcast = async (req, res) => {
link_url,
link_label,
color,
image_asset_id,
target_type,
target_id,
createdBy,
@@ -243,6 +272,8 @@ exports.createBroadcast = async (req, res) => {
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({
@@ -251,6 +282,7 @@ exports.createBroadcast = async (req, res) => {
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,
@@ -264,7 +296,7 @@ exports.createBroadcast = async (req, res) => {
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, "Announcement created.", { data: broadcast }, 201);
return R.success(res, "Alert created.", { data: broadcast }, 201);
} catch (dbErr) {
try { await t.rollback(); } catch { /* connection gone */ }
throw dbErr;
@@ -322,6 +354,7 @@ exports.updateBroadcast = async (req, res) => {
title: broadcast.title,
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,
@@ -334,7 +367,7 @@ exports.updateBroadcast = async (req, res) => {
for (const table of ['admin_notifications', 'user_notifications']) {
await sequelize.query(
`UPDATE ${table}
SET title = :title, message = :message, color = :color,
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)
@@ -347,7 +380,7 @@ exports.updateBroadcast = async (req, res) => {
await t.commit();
logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
return R.success(res, "Announcement updated.", { data: broadcast });
return R.success(res, "Alert updated.", { data: broadcast });
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
throw dbErr;
@@ -367,7 +400,7 @@ exports.sendBroadcast = async (req, res) => {
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, "Announcement not found.", 404);
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) {
@@ -395,7 +428,7 @@ exports.sendBroadcast = async (req, res) => {
if (targetType === 'admin' || targetType === 'both') {
await AdminNotification.create(
{ ...baseNotify, seen: false, show_in_sticky: showInSticky, show_in_notifications: showInNotifications, color: broadcast.color, start_date: broadcast.start_date, end_date: broadcast.end_date, broadcast_id: broadcast.broadcast_id },
{ ...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;
@@ -437,6 +470,7 @@ exports.sendBroadcast = async (req, res) => {
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,
@@ -453,7 +487,7 @@ exports.sendBroadcast = async (req, res) => {
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, "Announcement sent.", { data: broadcast });
return R.success(res, "Alert sent.", { data: broadcast });
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
throw dbErr;
@@ -473,12 +507,25 @@ exports.archiveBroadcast = async (req, res) => {
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, "Announcement not found.", 404);
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;
}
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, "Announcement archived.");
return R.success(res, "Alert archived.");
} catch (err) {
console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err);
return R.error(res, "Internal server error.", 500);
@@ -497,8 +544,20 @@ exports.archiveBroadcasts = async (req, res) => {
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 } } });
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.`, {
@@ -518,13 +577,30 @@ exports.restoreBroadcast = async (req, res) => {
const { broadcastId } = req.params;
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
if (!broadcast) return R.error(res, "Announcement not found.", 404);
if (!broadcast.deletedAt) return R.error(res, "Announcement is not archived.", 400);
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;
}
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, "Announcement restored.", { data: broadcast });
return R.success(res, "Alert restored.", { data: broadcast });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][RESTORE]", err);
return R.error(res, "Internal server error.", 500);
@@ -546,8 +622,27 @@ exports.restoreBroadcasts = async (req, res) => {
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 });
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.`, {
@@ -572,10 +667,10 @@ exports.getArchivedBroadcasts = async (req, res) => {
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
});
return R.success(res, "Archived announcements retrieved.", result);
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 announcements.", 500);
return R.error(res, "Could not retrieve archived alerts.", 500);
}
};
@@ -586,15 +681,15 @@ exports.permanentlyDeleteBroadcast = async (req, res) => {
const { broadcastId } = req.params;
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
if (!broadcast) return R.error(res, "Announcement not found.", 404);
if (!broadcast.deletedAt) return R.error(res, "Announcement must be archived before it can be permanently deleted.", 400);
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, "Announcement permanently deleted.");
return R.success(res, "Alert permanently deleted.");
} catch (err) {
console.error("[NOTIFICATION BROADCAST][PERMANENT DELETE]", err);
return R.error(res, "Could not permanently delete announcement.", 500);
return R.error(res, "Could not permanently delete alert.", 500);
}
};
@@ -606,81 +701,23 @@ exports.permanentlyDeleteBroadcasts = async (req, res) => {
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 announcements found.", 404);
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 announcements must be archived before they can be permanently deleted.", 400);
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} announcement(s) permanently deleted.`, {
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 announcements.", 500);
return R.error(res, "Could not permanently delete alerts.", 500);
}
};
// ─── STICKY BANNER (shared, singleton) ────────────────────────────────────────
// One image for the whole rotating sticky bar (up to 3 concurrent
// announcements share it) — not one per announcement. Set from the
// Announcements list page.
exports.getStickyBannerSetting = async (req, res) => {
try {
const setting = await StickyBannerSetting.findOne({ where: { id: 1 }, include: [IMAGE_INCLUDE] });
if (!setting) return R.success(res, "Sticky banner setting retrieved.", { data: null });
const json = setting.toJSON();
if (json.image) await attachImageStreamToken(json.image, req);
return R.success(res, "Sticky banner setting retrieved.", { data: json });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET STICKY BANNER]", err);
return R.error(res, "Could not retrieve sticky banner setting.", 500);
}
};
exports.updateStickyBannerSetting = async (req, res) => {
try {
const { image_asset_id, updatedBy } = req.body;
let validatedImageAssetId = null;
if (image_asset_id) {
const asset = await mdl_Assets.findOne({ where: { asset_id: image_asset_id, deletedAt: null } });
if (!asset) return R.error(res, "Selected image asset was not found.", 400);
validatedImageAssetId = asset.asset_id;
}
// Plain find-then-create/update rather than findOrCreate() — Sequelize's
// postgres findOrCreate() relies on a temp PL/pgSQL function for atomicity
// that CockroachDB doesn't support ("cannot create user-defined functions
// under a temporary schema") — same fix as trustedDevice.service.js.
let setting = await StickyBannerSetting.findOne({ where: { id: 1 } });
if (setting) {
setting.image_asset_id = validatedImageAssetId;
setting.updatedBy = updatedBy ?? null;
await setting.save();
} else {
setting = await StickyBannerSetting.create({ id: 1, image_asset_id: validatedImageAssetId, updatedBy: updatedBy ?? null });
}
// Reload with the image association so the response carries a fully
// resolved preview (stream token for S3) — same shape as the GET, so the
// frontend never needs to locally guess/merge in an optimistic image.
await setting.reload({ include: [IMAGE_INCLUDE] });
const json = setting.toJSON();
if (json.image) await attachImageStreamToken(json.image, req);
logActivity(req.user?.user_id, 'update_sticky_banner_setting', { entityType: 'sticky_banner_setting', entityId: 1 });
return R.success(res, "Sticky banner setting updated.", { data: json });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][UPDATE STICKY BANNER]", err);
return R.error(res, "Could not update sticky banner setting.", 500);
}
};