mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -94,10 +94,21 @@ async function applyAdvertisementFields(advertisement, body) {
|
||||
// status is intentionally NOT settable here — it's derived via deriveStatus()
|
||||
// right before save, based on is_active + start_date/end_date.
|
||||
|
||||
if (body.content_mode !== undefined) {
|
||||
if (!["image", "content"].includes(body.content_mode)) {
|
||||
const err = new Error(`Invalid content_mode. Must be one of: image, content`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
advertisement.content_mode = body.content_mode;
|
||||
}
|
||||
|
||||
if (body.badge_label !== undefined) advertisement.badge_label = body.badge_label;
|
||||
if (body.headline !== undefined) advertisement.headline = body.headline;
|
||||
if (body.description !== undefined) advertisement.description = body.description;
|
||||
if (body.image_url !== undefined) advertisement.image_url = body.image_url;
|
||||
if (body.redirect_link !== undefined) advertisement.redirect_link = body.redirect_link || null;
|
||||
if (body.landing_page !== undefined) advertisement.landing_page = body.landing_page || null;
|
||||
|
||||
if (body.image_asset_id !== undefined) {
|
||||
if (body.image_asset_id === null) {
|
||||
@@ -136,8 +147,27 @@ async function applyAdvertisementFields(advertisement, body) {
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Keeps the stored `status` column in sync with deriveStatus() before the
|
||||
// filtered query runs — status is otherwise only recomputed on individual
|
||||
// row reads, so filtering by status (e.g. "expired") would miss rows whose
|
||||
// start_date/end_date lapsed since they were last saved.
|
||||
async function syncDerivedStatuses() {
|
||||
await sequelize.query(`
|
||||
UPDATE advertisements
|
||||
SET status = CASE
|
||||
WHEN is_active = false THEN 'draft'
|
||||
WHEN end_date IS NOT NULL AND end_date < NOW() THEN 'expired'
|
||||
WHEN start_date IS NOT NULL AND start_date > NOW() THEN 'scheduled'
|
||||
ELSE 'active'
|
||||
END
|
||||
WHERE "deletedAt" IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
exports.getAdvertisements = async (req, res) => {
|
||||
try {
|
||||
await syncDerivedStatuses();
|
||||
|
||||
const result = await paginate(Advertisement, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
@@ -275,10 +305,6 @@ exports.updateAdvertisement = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// TODO(ads-7): Once expired (end_date passed), an advertisement should be
|
||||
// auto-archived (soft-deleted via the same path as archiveAdvertisement below)
|
||||
// instead of just sitting at derived status "expired" indefinitely. Add a
|
||||
// cron job — see TODO(ads-7) in new_starr/cron/client.cron.js.
|
||||
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveAdvertisement = async (req, res) => {
|
||||
@@ -382,7 +408,7 @@ exports.getArchivedAdvertisements = async (req, res) => {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
context: "archived",
|
||||
auditOptions: { mdl_Users, parentAlias: 'Advertisement' },
|
||||
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const { flattenUnits, flattenLessons, nextOrderIndex, reorderJunction } = requir
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { renderNotification } = require('../../services/notificationTemplate.service');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
|
||||
// ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2174,11 +2174,11 @@ exports.updateAssessment = async (req, res) => {
|
||||
where: { course_id: courseId },
|
||||
attributes: ['title', 'uuid'],
|
||||
});
|
||||
const notify = await renderNotification({ type: 'assessment_updated', data: {
|
||||
const notify = NOTIFICATION_REGISTRY.assessment_updated.build({
|
||||
assessmentTitle: assessment.title,
|
||||
courseTitle: course?.title ?? null,
|
||||
courseUuid: course?.uuid ?? null,
|
||||
} });
|
||||
});
|
||||
const now = new Date();
|
||||
await UserNotification.bulkCreate(
|
||||
inProgressSessions.map(({ user_id }) => ({
|
||||
|
||||
@@ -12,7 +12,44 @@
|
||||
* Date Created: Jun. 19, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const AdminNotification = require('../../models/notifications/admin_notification.mdl');
|
||||
const StickyBannerSetting = require('../../models/notifications/sticky_banner_setting.mdl');
|
||||
const mdl_Assets = require('../../models/assets/assets.mdl');
|
||||
const mediaToken = require('../../services/mediaToken.service');
|
||||
const R = require('../../utils/response.util');
|
||||
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
|
||||
|
||||
const STICKY_LIMIT = 3;
|
||||
const IMAGE_INCLUDE = {
|
||||
model: mdl_Assets,
|
||||
as: 'image',
|
||||
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"],
|
||||
required: false,
|
||||
};
|
||||
|
||||
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken.
|
||||
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;
|
||||
}
|
||||
|
||||
// One shared banner image for the whole rotating sticky bar (see
|
||||
// controllers/admin/notificationBroadcasts.controller.js's
|
||||
// getStickyBannerSetting/updateStickyBannerSetting) — not per-announcement.
|
||||
async function resolveSharedBannerImage(req) {
|
||||
const setting = await StickyBannerSetting.findOne({ where: { id: 1 }, include: [IMAGE_INCLUDE] });
|
||||
if (!setting?.image) return null;
|
||||
const image = setting.toJSON().image;
|
||||
await attachImageStreamToken(image, req);
|
||||
return image;
|
||||
}
|
||||
|
||||
// ─── GET /admin/notifications ─────────────────────────────────────────────────
|
||||
async function list(req, res) {
|
||||
@@ -25,7 +62,7 @@ async function list(req, res) {
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit,
|
||||
offset,
|
||||
where: { show_in_notifications: true },
|
||||
where: { show_in_notifications: true, ...notInFutureOrExpired() },
|
||||
});
|
||||
|
||||
return R.success(res, 'Notifications fetched.', {
|
||||
@@ -41,7 +78,7 @@ async function list(req, res) {
|
||||
// ─── GET /admin/notifications/unseen ─────────────────────────────────────────
|
||||
async function unseenCount(req, res) {
|
||||
try {
|
||||
const count = await AdminNotification.count({ where: { seen: false, show_in_notifications: true } });
|
||||
const count = await AdminNotification.count({ where: { seen: false, show_in_notifications: true, ...notInFutureOrExpired() } });
|
||||
return R.success(res, 'Unseen count fetched.', { count });
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION] unseenCount error:', err);
|
||||
@@ -54,16 +91,21 @@ async function unseenCount(req, res) {
|
||||
// banner for every admin. Whoever dismisses it first dismisses it for all.
|
||||
async function stickyAnnouncement(req, res) {
|
||||
try {
|
||||
const notification = await AdminNotification.findOne({
|
||||
where: {
|
||||
seen: false,
|
||||
show_in_sticky: true,
|
||||
type: 'announcement',
|
||||
},
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
const [notifications, bannerImage] = await Promise.all([
|
||||
AdminNotification.findAll({
|
||||
where: {
|
||||
seen: false,
|
||||
show_in_sticky: true,
|
||||
type: 'announcement',
|
||||
...notInFutureOrExpired(),
|
||||
},
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: STICKY_LIMIT,
|
||||
}),
|
||||
resolveSharedBannerImage(req),
|
||||
]);
|
||||
|
||||
return R.success(res, 'Sticky announcement fetched.', { announcement: notification });
|
||||
return R.success(res, 'Sticky announcements fetched.', { announcements: notifications, bannerImage });
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION] stickyAnnouncement error:', err);
|
||||
return R.error(res, 'Failed to fetch sticky announcement.');
|
||||
|
||||
@@ -4,16 +4,20 @@ 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');
|
||||
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,
|
||||
@@ -28,10 +32,58 @@ const { Op } = require('sequelize');
|
||||
|
||||
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 = 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.`;
|
||||
|
||||
async function applyBroadcastFields(broadcast, body) {
|
||||
if (body.title !== undefined) broadcast.title = body.title;
|
||||
if (body.message !== undefined) broadcast.message = body.message;
|
||||
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.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;
|
||||
@@ -161,11 +213,15 @@ exports.createBroadcast = async (req, res) => {
|
||||
title,
|
||||
message,
|
||||
link_url,
|
||||
link_label,
|
||||
color,
|
||||
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);
|
||||
@@ -181,6 +237,10 @@ exports.createBroadcast = async (req, res) => {
|
||||
return R.error(res, "At least one of show_in_sticky or show_in_notifications must be enabled.", 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 t = await sequelize.transaction();
|
||||
@@ -189,6 +249,10 @@ exports.createBroadcast = async (req, res) => {
|
||||
title,
|
||||
message,
|
||||
link_url: link_url?.trim() || null,
|
||||
link_label: link_label?.trim() || null,
|
||||
color: color || 'indigo',
|
||||
start_date: start_date || null,
|
||||
end_date: end_date || null,
|
||||
createdBy,
|
||||
status: 'draft',
|
||||
target_type,
|
||||
@@ -222,7 +286,7 @@ exports.updateBroadcast = async (req, res) => {
|
||||
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 wasActiveSticky = broadcast.status === 'sent' && broadcast.show_in_sticky;
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
@@ -232,8 +296,54 @@ exports.updateBroadcast = async (req, res) => {
|
||||
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,
|
||||
message: broadcast.message,
|
||||
color: broadcast.color,
|
||||
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,
|
||||
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) });
|
||||
@@ -260,6 +370,10 @@ exports.sendBroadcast = async (req, res) => {
|
||||
if (!broadcast) return R.error(res, "Announcement 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();
|
||||
@@ -275,12 +389,13 @@ exports.sendBroadcast = async (req, res) => {
|
||||
message: broadcast.message,
|
||||
targetType,
|
||||
targetId,
|
||||
linkUrl: broadcast.link_url,
|
||||
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 },
|
||||
{ ...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 },
|
||||
{ transaction: t }
|
||||
);
|
||||
recipientCount += 1;
|
||||
@@ -312,7 +427,8 @@ exports.sendBroadcast = async (req, res) => {
|
||||
? NOTIFICATION_REGISTRY.broadcast.build({
|
||||
title: broadcast.title, message: broadcast.message, targetType, targetId,
|
||||
groupId: groupByUser[user_id] ?? null,
|
||||
linkUrl: broadcast.link_url,
|
||||
linkUrl: broadcast.link_url,
|
||||
linkLabel: broadcast.link_label,
|
||||
})
|
||||
: baseNotify),
|
||||
seen: false,
|
||||
@@ -320,6 +436,10 @@ exports.sendBroadcast = async (req, res) => {
|
||||
updatedAt: now,
|
||||
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,
|
||||
})),
|
||||
{ validate: false, transaction: t }
|
||||
);
|
||||
@@ -505,3 +625,62 @@ exports.permanentlyDeleteBroadcasts = async (req, res) => {
|
||||
return R.error(res, "Could not permanently delete announcements.", 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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const mdl_NotificationTemplate = require('../../models/notifications/notification_template.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
const slugify = (str) =>
|
||||
str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/(^_|_$)/g, '');
|
||||
|
||||
// ─── GET /admin/notification-templates ─────────────────────────────────────────
|
||||
|
||||
exports.getNotificationTemplates = async (req, res) => {
|
||||
try {
|
||||
const templates = await mdl_NotificationTemplate.findAll({
|
||||
order: [['notify_type', 'ASC'], ['type', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Announcement templates retrieved.', templates);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET NOTIFICATION TEMPLATES]', err);
|
||||
return R.error(res, 'Could not retrieve announcement templates.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /admin/notification-templates/:id ─────────────────────────────────────
|
||||
|
||||
exports.getNotificationTemplate = async (req, res) => {
|
||||
try {
|
||||
const template = await mdl_NotificationTemplate.findByPk(req.params.id);
|
||||
if (!template) return R.error(res, 'Announcement template not found.', 404);
|
||||
return R.success(res, 'Announcement template retrieved.', template);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET NOTIFICATION TEMPLATE]', err);
|
||||
return R.error(res, 'Could not retrieve announcement template.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/notification-templates ────────────────────────────────────────
|
||||
// Only creates custom (is_system: false) rows. System types still can't be
|
||||
// added here — they need a code call site (services/notificationTemplate
|
||||
// .service.js's renderNotification()) before a type means anything. Custom
|
||||
// rows have no call site at all: they're reusable title/message presets an
|
||||
// admin can load into the Announcements composer (see AddNotificationBroadcast
|
||||
// .jsx), so `type` only exists to satisfy the unique key — nothing looks it up.
|
||||
|
||||
exports.createNotificationTemplate = async (req, res) => {
|
||||
try {
|
||||
const { label, title, message } = req.body;
|
||||
if (!label?.trim()) return R.error(res, 'label is required.', 400);
|
||||
if (!title?.trim()) return R.error(res, 'title is required.', 400);
|
||||
if (!message?.trim()) return R.error(res, 'message cannot be empty.', 400);
|
||||
|
||||
const base = slugify(label) || 'template';
|
||||
let type = `custom_${base}`;
|
||||
let suffix = 1;
|
||||
while (await mdl_NotificationTemplate.findOne({ where: { type } })) {
|
||||
suffix += 1;
|
||||
type = `custom_${base}_${suffix}`;
|
||||
}
|
||||
|
||||
const template = await mdl_NotificationTemplate.create({
|
||||
type,
|
||||
notify_type: 'announcement',
|
||||
scope: 'both',
|
||||
label: label.trim(),
|
||||
status: 'sent',
|
||||
title: title.trim(),
|
||||
message: message.trim(),
|
||||
is_system: false,
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'create_notification_template', { entityType: 'notification_template', entityId: template.notification_template_id, details: { label: template.label } });
|
||||
|
||||
return R.success(res, 'Announcement template created.', template, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE NOTIFICATION TEMPLATE]', err);
|
||||
return R.error(res, 'Could not create announcement template.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE /admin/notification-templates/:id ──────────────────────────────────
|
||||
// System templates stay protected — deleting one would break the code call
|
||||
// site that references its type.
|
||||
|
||||
exports.deleteNotificationTemplate = async (req, res) => {
|
||||
try {
|
||||
const template = await mdl_NotificationTemplate.findByPk(req.params.id);
|
||||
if (!template) return R.error(res, 'Announcement template not found.', 404);
|
||||
if (template.is_system) return R.error(res, 'System templates cannot be deleted.', 400);
|
||||
|
||||
await template.destroy();
|
||||
|
||||
logActivity(req.user?.user_id, 'delete_notification_template', { entityType: 'notification_template', entityId: template.notification_template_id, details: { label: template.label } });
|
||||
|
||||
return R.success(res, 'Announcement template deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DELETE NOTIFICATION TEMPLATE]', err);
|
||||
return R.error(res, 'Could not delete announcement template.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT /admin/notification-templates/:id ─────────────────────────────────────
|
||||
|
||||
exports.updateNotificationTemplate = async (req, res) => {
|
||||
try {
|
||||
const template = await mdl_NotificationTemplate.findByPk(req.params.id);
|
||||
if (!template) return R.error(res, 'Announcement template not found.', 404);
|
||||
|
||||
const { label, title, message, publish } = req.body;
|
||||
|
||||
if (title !== undefined && !title.trim()) return R.error(res, 'title cannot be empty.', 400);
|
||||
if (message !== undefined && !message.trim()) return R.error(res, 'message cannot be empty.', 400);
|
||||
|
||||
// Custom templates are just reusable presets — nothing reads them at a
|
||||
// fixed publish time, so there's no draft/publish workflow: title/message
|
||||
// save straight to the live columns.
|
||||
if (!template.is_system) {
|
||||
await template.update({
|
||||
label: label ?? template.label,
|
||||
title: title ?? template.title,
|
||||
message: message ?? template.message,
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'update_notification_template', { entityType: 'notification_template', entityId: template.notification_template_id, details: { custom: true } });
|
||||
|
||||
return R.success(res, 'Announcement template updated.', template);
|
||||
}
|
||||
|
||||
// "Publish" writes title/message straight to the live columns
|
||||
// renderNotification() reads and clears any pending draft. A plain save
|
||||
// (no publish flag) writes into draft_title/draft_message instead, so
|
||||
// real notifications keep using the last-published content until an
|
||||
// admin comes back and explicitly publishes again.
|
||||
const isPublishing = publish === true || publish === 'true';
|
||||
const nextTitle = title ?? template.draft_title ?? template.title;
|
||||
const nextMessage = message ?? template.draft_message ?? template.message;
|
||||
|
||||
await template.update({
|
||||
label: label ?? template.label,
|
||||
...(isPublishing
|
||||
? {
|
||||
status: 'sent',
|
||||
title: nextTitle,
|
||||
message: nextMessage,
|
||||
draft_title: null,
|
||||
draft_message: null,
|
||||
last_sent_at: new Date(),
|
||||
}
|
||||
: {
|
||||
draft_title: nextTitle,
|
||||
draft_message: nextMessage,
|
||||
}),
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'update_notification_template', { entityType: 'notification_template', entityId: template.notification_template_id, details: { type: template.type, published: isPublishing } });
|
||||
|
||||
return R.success(res, 'Announcement template updated.', template);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPDATE NOTIFICATION TEMPLATE]', err);
|
||||
return R.error(res, 'Could not update announcement template.', 500);
|
||||
}
|
||||
};
|
||||
@@ -14,7 +14,7 @@ const { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups } = requi
|
||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { renderNotification } = require('../../services/notificationTemplate.service');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { adminExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes');
|
||||
|
||||
const R = require('../../utils/response.util');
|
||||
@@ -439,10 +439,10 @@ exports.assignGroups = async (req, res) => {
|
||||
|
||||
if (userIds.length) {
|
||||
const now = new Date();
|
||||
const notify = await renderNotification({ type: 'task_assigned', data: {
|
||||
const notify = NOTIFICATION_REGISTRY.task_assigned.build({
|
||||
taskListName: taskList.name,
|
||||
taskCount,
|
||||
} });
|
||||
});
|
||||
await UserNotification.bulkCreate(
|
||||
userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })),
|
||||
{ validate: false }
|
||||
@@ -763,11 +763,11 @@ exports.updateTask = async (req, res) => {
|
||||
const now = new Date();
|
||||
// Title/message are identical for every member — render once,
|
||||
// then vary only the per-member groupId in the data payload.
|
||||
const notify = await renderNotification({ type: 'task_requirements_updated', data: {
|
||||
const notify = NOTIFICATION_REGISTRY.task_requirements_updated.build({
|
||||
taskName: full.name,
|
||||
taskListId: task.task_list_id,
|
||||
groupId: null,
|
||||
} });
|
||||
});
|
||||
await UserNotification.bulkCreate(
|
||||
members.map(({ user_id, group_id }) => ({
|
||||
user_id,
|
||||
|
||||
@@ -15,7 +15,7 @@ const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_c
|
||||
const { Task } = require('../../models/task/task.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { renderNotification } = require('../../services/notificationTemplate.service');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { checkTaskCompletion, fireTaskCompletedEvent } = require('../client/task.controller');
|
||||
|
||||
const { adminExclude } = require('../../models/task/task_completion.attributes');
|
||||
@@ -190,9 +190,9 @@ exports.reviewSubmission = async (req, res) => {
|
||||
});
|
||||
|
||||
try {
|
||||
const notify = await renderNotification({ type: 'task_submission_reviewed', data: {
|
||||
const notify = NOTIFICATION_REGISTRY.task_submission_reviewed.build({
|
||||
taskName: task.name, status, review_note: review_note || null,
|
||||
} });
|
||||
});
|
||||
await UserNotification.create({ user_id: completion.user_id, ...notify, seen: false });
|
||||
} catch (notifyErr) {
|
||||
console.error('[ADMIN][REVIEW SUBMISSION][NOTIFY]', notifyErr);
|
||||
|
||||
Reference in New Issue
Block a user