units,lesson as standalone

This commit is contained in:
2026-07-10 11:44:46 +08:00
parent 86fba50b95
commit e1ffdab190
46 changed files with 1463 additions and 197 deletions
@@ -32,6 +32,9 @@ async function applyBroadcastFields(broadcast, body) {
if (body.title !== undefined) broadcast.title = body.title;
if (body.message !== undefined) broadcast.message = body.message;
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(", ")}`);
@@ -101,10 +104,10 @@ exports.getBroadcasts = async (req, res) => {
if (Array.isArray(result?.data)) await attachTargetLabels(result.data);
return R.success(res, "Notification broadcasts retrieved.", result);
return R.success(res, "Announcements retrieved.", result);
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ALL]", err);
return R.error(res, "Could not retrieve notification broadcasts.", 500);
return R.error(res, "Could not retrieve announcements.", 500);
}
};
@@ -123,7 +126,7 @@ exports.getBroadcast = async (req, res) => {
],
});
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
if (!broadcast) return R.error(res, "Announcement not found.", 404);
const json = broadcast.toJSON();
@@ -142,7 +145,7 @@ exports.getBroadcast = async (req, res) => {
await attachTargetLabels(json);
return R.success(res, "Notification broadcast retrieved.", { data: json });
return R.success(res, "Announcement retrieved.", { data: json });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ONE]", err);
return R.error(res, "Internal server error.", 500);
@@ -153,7 +156,15 @@ exports.getBroadcast = async (req, res) => {
exports.createBroadcast = async (req, res) => {
try {
const { title, message, target_type, target_id, createdBy } = req.body;
const {
title,
message,
target_type,
target_id,
createdBy,
show_in_sticky,
show_in_notifications,
} = req.body;
if (!title) return R.error(res, "title is required.", 400);
if (!message) return R.error(res, "message is required.", 400);
@@ -162,20 +173,31 @@ exports.createBroadcast = async (req, res) => {
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 (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',
title,
message,
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, "Notification broadcast created.", { data: broadcast }, 201);
return R.success(res, "Announcement created.", { data: broadcast }, 201);
} catch (dbErr) {
try { await t.rollback(); } catch { /* connection gone */ }
throw dbErr;
@@ -202,12 +224,17 @@ exports.updateBroadcast = async (req, res) => {
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;
}
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 });
return R.success(res, "Announcement updated.", { data: broadcast });
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
throw dbErr;
@@ -227,7 +254,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, "Notification broadcast not found.", 404);
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);
const t = await sequelize.transaction();
@@ -237,6 +264,8 @@ exports.sendBroadcast = async (req, res) => {
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,
@@ -246,7 +275,10 @@ exports.sendBroadcast = async (req, res) => {
});
if (targetType === 'admin' || targetType === 'both') {
await AdminNotification.create({ ...baseNotify, seen: false }, { transaction: t });
await AdminNotification.create(
{ ...baseNotify, seen: false, show_in_sticky: showInSticky, show_in_notifications: showInNotifications },
{ transaction: t }
);
recipientCount += 1;
}
@@ -281,6 +313,8 @@ exports.sendBroadcast = async (req, res) => {
seen: false,
createdAt: now,
updatedAt: now,
show_in_sticky: showInSticky,
show_in_notifications: showInNotifications,
})),
{ validate: false, transaction: t }
);
@@ -294,7 +328,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, "Notification broadcast sent.", { data: broadcast });
return R.success(res, "Announcement sent.", { data: broadcast });
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
throw dbErr;
@@ -314,12 +348,12 @@ 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, "Notification broadcast not found.", 404);
if (!broadcast) return R.error(res, "Announcement 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.");
return R.success(res, "Announcement archived.");
} catch (err) {
console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err);
return R.error(res, "Internal server error.", 500);
@@ -359,13 +393,13 @@ 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, "Notification broadcast not found.", 404);
if (!broadcast.deletedAt) return R.error(res, "Notification broadcast is not archived.", 400);
if (!broadcast) return R.error(res, "Announcement not found.", 404);
if (!broadcast.deletedAt) return R.error(res, "Announcement 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 });
return R.success(res, "Announcement restored.", { data: broadcast });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][RESTORE]", err);
return R.error(res, "Internal server error.", 500);
@@ -413,10 +447,10 @@ exports.getArchivedBroadcasts = async (req, res) => {
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
});
return R.success(res, "Archived notification broadcasts retrieved.", result);
return R.success(res, "Archived announcements retrieved.", result);
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ARCHIVED]", err);
return R.error(res, "Could not retrieve archived notification broadcasts.", 500);
return R.error(res, "Could not retrieve archived announcements.", 500);
}
};
@@ -427,15 +461,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, "Notification broadcast not found.", 404);
if (!broadcast.deletedAt) return R.error(res, "Notification broadcast must be archived before it can be permanently deleted.", 400);
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);
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.");
return R.success(res, "Announcement permanently deleted.");
} catch (err) {
console.error("[NOTIFICATION BROADCAST][PERMANENT DELETE]", err);
return R.error(res, "Could not permanently delete notification broadcast.", 500);
return R.error(res, "Could not permanently delete announcement.", 500);
}
};
@@ -447,22 +481,22 @@ 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 notification broadcasts found.", 404);
if (!broadcasts.length) return R.error(res, "No announcements 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);
if (!archived.length) return R.error(res, "All selected announcements 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.`, {
return R.success(res, `${archivedIds.length} announcement(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);
return R.error(res, "Could not permanently delete announcements.", 500);
}
};