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
@@ -259,6 +259,13 @@ exports.createAdvertisement = async (req, res) => {
try {
const advertisement = await Advertisement.build({ placement, type: entry.format, createdBy });
await applyAdvertisementFields(advertisement, req.body);
// No manual "order" input in the UI anymore — new ads always append to
// the end of their placement's priority list rather than colliding at 0.
if (req.body.order === undefined) {
advertisement.order = await Advertisement.count({ where: { placement, ...notDeleted }, transaction: t });
}
await advertisement.save({ transaction: t });
await t.commit();
@@ -305,6 +312,57 @@ exports.updateAdvertisement = async (req, res) => {
}
};
// ─── REORDER ──────────────────────────────────────────────────────────────────
// `order` is scoped per placement (mirrors controllers/client/advertisements.controller.js's
// getActiveAdvertisement[List], which picks the lowest `order` within a placement to show
// first) — Move Up/Down swaps position among siblings sharing the same placement, then
// re-sequences the whole group to 0..n-1. Re-sequencing (not just swapping the two `order`
// values) is what makes this self-healing against legacy ties, since every ad defaulted to
// order: 0 before this feature existed — a plain swap between two tied rows would no-op.
exports.reorderAdvertisement = async (req, res) => {
try {
const { advertisementId } = req.params;
const { direction } = req.body;
if (!["up", "down"].includes(direction)) return R.error(res, "direction must be 'up' or 'down'.", 400);
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } });
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
const group = await Advertisement.findAll({
where: { placement: advertisement.placement, ...notDeleted },
order: [["order", "ASC"], ["createdAt", "DESC"]],
});
const index = group.findIndex((a) => a.advertisement_id === advertisement.advertisement_id);
const swapWith = direction === "up" ? index - 1 : index + 1;
if (swapWith < 0 || swapWith >= group.length) {
return R.error(res, `This ad is already at the ${direction === "up" ? "top" : "bottom"} of its placement.`, 400);
}
[group[index], group[swapWith]] = [group[swapWith], group[index]];
const t = await sequelize.transaction();
try {
await Promise.all(group.map((a, i) => a.update({ order: i }, { transaction: t })));
await t.commit();
} catch (dbErr) {
try { await t.rollback(); } catch { /* connection gone */ }
throw dbErr;
}
logActivity(req.user?.user_id, 'reorder_advertisement', {
entityType: 'advertisement', entityId: Number(advertisementId),
details: { placement: advertisement.placement, direction },
});
return R.success(res, "Order updated.", {
data: { updates: group.map((a) => ({ advertisement_id: a.advertisement_id, order: a.order })) },
});
} catch (err) {
console.error("[ADVERTISEMENT][REORDER]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
exports.archiveAdvertisement = async (req, res) => {
+27 -96
View File
@@ -1,16 +1,15 @@
// controllers/admin/assets.controller.js
const path = require("path");
const fs = require("fs");
const sequelize = require("../../config/db.config");
const Asset = require("../../models/assets/assets.mdl");
const chibi = require("../../services/chibisafe.service");
const s3 = require("../../services/s3.service");
const mediaToken = require("../../services/mediaToken.service");
const uploadProgress = require("../../services/uploadProgress.service");
const { extractVideoMeta } = require("../../services/ffprobe.service");
const ffmpegSvc = require("../../services/ffmpeg.service");
const assetTranscode = require("../../services/assetTranscode.service");
const documentConversion = require("../../services/documentConversion.service");
const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/assets/assets.attributes");
@@ -58,15 +57,6 @@ function resolveExtension(originalName = "") {
return path.extname(originalName).replace(".", "").toLowerCase() || null;
}
function streamToBuffer(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
});
}
function resolveResolution(width, height) {
if (!width || !height) return null;
const h = Math.min(width, height);
@@ -398,7 +388,31 @@ async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, or
video_codec = videoMeta.video_codec;
audio_codec = videoMeta.audio_codec;
if (thumbnail_storage_key) thumbnail_url = await svc.buildPublicUrl(thumbnail_storage_key);
if (thumbnail_storage_key) {
thumbnail_url = await svc.buildPublicUrl(thumbnail_storage_key);
} else if (file_type === "video") {
// No client-provided thumbnail — grab a frame from the video itself so
// the asset doesn't sit with no preview at all in every picker/library
// grid. Best-effort: a failure here must not fail the whole upload.
let framePath = null;
try {
framePath = await ffmpegSvc.extractFrameThumbnail(probeUrl, duration);
const uploaded = await svc.uploadStream({
stream: fs.createReadStream(framePath),
originalname: `${(original_name || "thumb").replace(/\.[^.]+$/, "")}.jpg`,
mimetype: "image/jpeg",
ownerType: "thumbnail", // → thumbnails/ prefix, same as manually-uploaded thumbnails
});
thumbnail_storage_key = uploaded.uuid;
thumbnail_url = uploaded.url;
uploadedFiles.push({ key: thumbnail_storage_key, provider: storage_provider }); // rollback cleanup on later failure
} catch (err) {
console.warn(`[ASSET][THUMBNAIL] Auto-generate failed for "${storage_key}":`, err.message);
// leave thumbnail_url null — same fallback as before, admin can add one manually later
} finally {
if (framePath) fs.promises.unlink(framePath).catch(() => {});
}
}
} else {
const parsedWidth = body.width ? parseInt(body.width) : null;
@@ -435,6 +449,7 @@ async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, or
video_codec,
audio_codec,
thumbnail_url,
thumbnail_storage_key,
description,
storage_provider,
storage_bucket: storage_bucket || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null,
@@ -572,90 +587,6 @@ exports.uploadAsset = async (req, res) => {
}
};
// Same broadcaster, reused as-is for document-conversion job progress — it's
// just a generic string-keyed SSE channel, nothing upload-specific about it.
exports.streamConvertProgress = (req, res) => {
uploadProgress.subscribe(req.params.jobId, res);
};
// ─── CONVERT TO MARKDOWN ────────────────────────────────────────────────────
//
// PDF/PPTX -> Markdown, text only (see services/documentConversion.service.js
// for the compile/validate/automate stages and why OCR/images are out of
// scope). Nothing here is written to the database — the caller (the Lesson
// block builder) treats the response as a draft and only persists it if/when
// the admin explicitly inserts it into a block and saves the lesson page.
//
// Mirrors the upload flow's SSE progress pattern exactly: the actual work
// runs synchronously inside this request (uploadProgress.service.js is
// reused as-is, keyed by a client-generated jobId) while stage transitions
// are published for a live "Compiling / Validating / Generating" UI, same as
// AddAsset.jsx already renders for uploads.
//
const CONVERTIBLE_EXTENSIONS = new Set(["pdf", "pptx"]);
const MAX_CONVERT_SIZE_BYTES = 25 * 1024 * 1024; // 25MB — keeps this comfortably synchronous
// compile() calls the MarkItDown sidecar over HTTP now instead of running
// officeparser in-process, so this leaves a bit more room than the original
// 45s for network/queueing overhead — MarkItDown's own parsing is plain
// CPU-bound work, not ML inference, so it doesn't need much more than that.
const CONVERT_TIMEOUT_MS = 60_000;
exports.convertAssetToMarkdown = async (req, res) => {
const { jobId } = req.body;
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
if (!asset) return R.error(res, "Asset not found.", 404);
const extension = (asset.extension || "").toLowerCase();
if (!CONVERTIBLE_EXTENSIONS.has(extension)) {
return R.error(res, "Only PDF and PPTX documents can be converted to Markdown.", 400);
}
if (asset.storage_provider !== "s3" || !asset.storage_key) {
return R.error(res, "This asset has no stored file to convert.", 400);
}
if (Number(asset.file_size) > MAX_CONVERT_SIZE_BYTES) {
return R.error(res, "File is too large to convert (25MB max).", 400);
}
const publish = (phase) => { if (jobId) uploadProgress.publish(jobId, { phase }); };
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), CONVERT_TIMEOUT_MS);
let markdown, warnings, stats;
try {
publish("compiling");
const { stream } = await s3.getObjectStream(asset.storage_key);
const buffer = await streamToBuffer(stream);
const ast = await documentConversion.compile(buffer, extension, { signal: abortController.signal });
publish("validating");
const validated = documentConversion.validate(ast);
publish("generating");
const generated = await documentConversion.automate(ast, { signal: abortController.signal });
markdown = generated.markdown;
warnings = [...validated.warnings, ...generated.messages];
stats = validated.stats;
} finally {
clearTimeout(timeout);
}
if (jobId) uploadProgress.complete(jobId, { phase: "done" });
return R.success(res, "Document converted.", { markdown, warnings, stats });
} catch (err) {
if (jobId) uploadProgress.complete(jobId, { phase: "error", message: err.message });
console.error("[ASSET][CONVERT TO MARKDOWN]", err);
if (err.status) return R.error(res, err.message, err.status);
return R.error(res, "Internal server error.", 500);
}
};
// ─── UPDATE ───────────────────────────────────────────────────────────────────
exports.updateAsset = async (req, res) => {
+134 -4
View File
@@ -1,15 +1,32 @@
'use strict';
const { Op } = require('sequelize');
const mdl_Category = require('../../models/courses/categories.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const { CourseProductCategory } = require('../../models/courses/courses.mdl');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const { paginate } = require('../../utils/paginate.util');
const {
excludeAttributes: categoriesExclude,
jsonbSchemas: categoriesSchemas,
} = require('../../models/courses/categories.attributes');
const slugify = (str) =>
str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
exports.getCategories = async (req, res) => {
try {
const rows = await mdl_Category.findAll({ order: [['name', 'ASC']], paranoid: false });
return R.success(res, 'Categories retrieved.', rows);
const archived = req.query.archived === 'true';
const result = await paginate(mdl_Category, req, {
excludeAttributes: categoriesExclude,
jsonbSchemas: categoriesSchemas,
context: archived ? 'archived' : 'list',
auditOptions: { mdl_Users, parentAlias: 'Category' },
findOptions: archived
? { paranoid: false, where: { deletedAt: { [Op.ne]: null } } }
: {},
});
return R.success(res, 'Categories retrieved.', result);
} catch (err) {
console.error('[ADMIN][CATEGORIES][GET ALL]', err);
return R.error(res, 'Could not retrieve categories.', 500);
@@ -33,7 +50,10 @@ exports.createCategory = async (req, res) => {
if (!name) return R.error(res, 'name is required.', 400);
const slug = slugify(name);
const row = await mdl_Category.create({ name, slug, description: description ?? null, is_active: is_active ?? true });
const row = await mdl_Category.create({
name, slug, description: description ?? null, is_active: is_active ?? true,
createdBy: req.body.createdBy ?? req.user?.user_id ?? null,
});
logActivity(req.user?.user_id, 'create_category', { entityType: 'category', entityId: row.category_id, details: { name: row.name } });
return R.success(res, 'Category created.', row, 201);
} catch (err) {
@@ -51,7 +71,10 @@ exports.updateCategory = async (req, res) => {
const { name, description, is_active } = req.body;
const slug = name ? slugify(name) : row.slug;
await row.update({ name: name ?? row.name, slug, description: description ?? row.description, is_active: is_active ?? row.is_active });
await row.update({
name: name ?? row.name, slug, description: description ?? row.description, is_active: is_active ?? row.is_active,
updatedBy: req.body.updatedBy ?? req.user?.user_id ?? null,
});
logActivity(req.user?.user_id, 'update_category', { entityType: 'category', entityId: row.category_id });
return R.success(res, 'Category updated.', row);
} catch (err) {
@@ -66,6 +89,7 @@ exports.archiveCategory = async (req, res) => {
try {
const row = await mdl_Category.findByPk(req.params.id);
if (!row) return R.error(res, 'Category not found.', 404);
await row.update({ deletedBy: req.user?.user_id ?? null, is_active: false });
await row.destroy();
logActivity(req.user?.user_id, 'archive_category', { entityType: 'category', entityId: row.category_id });
return R.success(res, 'Category archived.');
@@ -79,7 +103,9 @@ exports.restoreCategory = async (req, res) => {
try {
const row = await mdl_Category.findByPk(req.params.id, { paranoid: false });
if (!row) return R.error(res, 'Category not found.', 404);
if (!row.deletedAt) return R.error(res, 'Category is not archived.', 400);
await row.restore();
await row.update({ deletedBy: null, is_active: true });
logActivity(req.user?.user_id, 'restore_category', { entityType: 'category', entityId: row.category_id });
return R.success(res, 'Category restored.', row);
} catch (err) {
@@ -87,3 +113,107 @@ exports.restoreCategory = async (req, res) => {
return R.error(res, 'Could not restore category.', 500);
}
};
exports.bulkArchiveCategories = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No category IDs provided.', 400);
const rows = await mdl_Category.findAll({ where: { id: ids } });
if (!rows.length) return R.error(res, 'No categories found.', 404);
const activeIds = rows.map((r) => r.id);
await Promise.all(rows.map((r) => r.update({ deletedBy: req.user?.user_id ?? null, is_active: false })));
await mdl_Category.destroy({ where: { id: activeIds } });
logActivity(req.user?.user_id, 'bulk_archive_categories', { entityType: 'category', details: { ids: activeIds, count: activeIds.length } });
return R.success(res, `${activeIds.length} categor${activeIds.length !== 1 ? 'ies' : 'y'} archived.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
});
} catch (err) {
console.error('[ADMIN][CATEGORIES][BULK ARCHIVE]', err);
return R.error(res, 'Could not archive categories.', 500);
}
};
exports.getCategoryPermanentDeleteImpact = async (req, res) => {
try {
const row = await mdl_Category.findByPk(req.params.id, { paranoid: false });
if (!row) return R.error(res, 'Category not found.', 404);
const course_count = await CourseProductCategory.count({ where: { category_id: req.params.id } });
return R.success(res, 'Category permanent-delete impact retrieved.', { course_count });
} catch (err) {
console.error('[ADMIN][CATEGORIES][PERMANENT DELETE IMPACT]', err);
return R.error(res, 'Could not retrieve category permanent-delete impact.', 500);
}
};
exports.permanentlyDeleteCategory = async (req, res) => {
try {
const row = await mdl_Category.findByPk(req.params.id, { paranoid: false });
if (!row) return R.error(res, 'Category not found.', 404);
if (!row.deletedAt) return R.error(res, 'Category must be archived before it can be permanently deleted.', 400);
// course_product_categories.category_id has no DB-level cascade (only course_id does) —
// clean up the junction rows explicitly or they'd be left orphaned.
await CourseProductCategory.destroy({ where: { category_id: row.id } });
await row.destroy({ force: true });
logActivity(req.user?.user_id, 'permanently_delete_category', { entityType: 'category', entityId: row.id, details: { name: row.name } });
return R.success(res, 'Category permanently deleted.');
} catch (err) {
console.error('[ADMIN][CATEGORIES][PERMANENT DELETE]', err);
return R.error(res, 'Could not permanently delete category.', 500);
}
};
exports.bulkRestoreCategories = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No category IDs provided.', 400);
const rows = await mdl_Category.findAll({ where: { id: ids }, paranoid: false });
if (!rows.length) return R.error(res, 'No categories found.', 404);
const archivedRows = rows.filter((r) => r.deletedAt);
if (!archivedRows.length) return R.error(res, 'All selected categories are already active.', 400);
const archivedIds = archivedRows.map((r) => r.id);
await mdl_Category.restore({ where: { id: archivedIds } });
await mdl_Category.update({ deletedBy: null, is_active: true }, { where: { id: archivedIds }, paranoid: false });
logActivity(req.user?.user_id, 'bulk_restore_categories', { entityType: 'category', details: { ids: archivedIds, count: archivedIds.length } });
return R.success(res, `${archivedIds.length} categor${archivedIds.length !== 1 ? 'ies' : 'y'} restored.`, {
restored_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
});
} catch (err) {
console.error('[ADMIN][CATEGORIES][BULK RESTORE]', err);
return R.error(res, 'Could not restore categories.', 500);
}
};
exports.bulkPermanentlyDeleteCategories = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No category IDs provided.', 400);
const rows = await mdl_Category.findAll({ where: { id: ids }, paranoid: false });
if (!rows.length) return R.error(res, 'No categories found.', 404);
const archivedRows = rows.filter((r) => r.deletedAt);
if (!archivedRows.length) return R.error(res, 'All selected categories must be archived before they can be permanently deleted.', 400);
const archivedIds = archivedRows.map((r) => r.id);
await CourseProductCategory.destroy({ where: { category_id: archivedIds } });
await mdl_Category.destroy({ where: { id: archivedIds }, force: true });
logActivity(req.user?.user_id, 'bulk_permanently_delete_categories', { entityType: 'category', details: { ids: archivedIds, count: archivedIds.length } });
return R.success(res, `${archivedIds.length} categor${archivedIds.length !== 1 ? 'ies' : 'y'} permanently deleted.`, {
deleted_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
});
} catch (err) {
console.error('[ADMIN][CATEGORIES][BULK PERMANENT DELETE]', err);
return R.error(res, 'Could not permanently delete categories.', 500);
}
};
@@ -17,7 +17,10 @@
const { Op } = require('sequelize');
const R = require('../../utils/response.util');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const { Course, Unit, Lesson, CourseUnit } = require('../../models/courses/courses.associations');
const {
Course, Unit, Lesson, CourseUnit,
UnitQuiz, CourseAssessment, QuizAttempt,
} = require('../../models/courses/courses.associations');
const mdl_Users = require('../../models/users/users.mdl');
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
const { resolveAvatarUrl } = require('../../utils/resolveAvatar.util');
@@ -68,6 +71,42 @@ exports.getCourseReadingProgress = async (req, res) => {
});
const userMap = Object.fromEntries(users.map((u) => [u.user_id, u]));
// Quiz/assessment gating — mirrors controllers/client/course_reading_progress
// .controller.js#getMyInProgressCourses: a unit quiz or course assessment that
// exists but hasn't been passed yet is why a user with 1/1 lessons read can still
// be 'in_progress'. Batched once per course across all users in this list.
const unitQuizzes = unitIds.length
? await UnitQuiz.findAll({ where: { unit_id: unitIds, ...notDeleted }, attributes: ['quiz_id'] })
: [];
const quizIds = unitQuizzes.map((q) => q.quiz_id);
const assessment = await CourseAssessment.findOne({
where: { course_id: courseId },
attributes: ['assessment_id'],
});
const passedQuizIdsByUser = new Map();
const passedAssessmentUserIds = new Set();
if (quizIds.length || assessment) {
const passedAttempts = await QuizAttempt.findAll({
where: {
user_id: userIds,
passed: true,
[Op.or]: [
...(quizIds.length ? [{ quiz_id: quizIds }] : []),
...(assessment ? [{ assessment_id: assessment.assessment_id }] : []),
],
},
attributes: ['user_id', 'quiz_id', 'assessment_id'],
});
for (const attempt of passedAttempts) {
if (attempt.quiz_id) {
if (!passedQuizIdsByUser.has(attempt.user_id)) passedQuizIdsByUser.set(attempt.user_id, new Set());
passedQuizIdsByUser.get(attempt.user_id).add(attempt.quiz_id);
}
if (attempt.assessment_id) passedAssessmentUserIds.add(attempt.user_id);
}
}
// Build per-user summary
const summaryMap = {};
for (const row of rows) {
@@ -106,6 +145,8 @@ exports.getCourseReadingProgress = async (req, res) => {
lessons_total,
// Fall back to in_progress if the course row hasn't been written yet
course_status: entry.course_status ?? 'in_progress',
quizzes_pending: quizIds.length - (passedQuizIdsByUser.get(entry.user_id)?.size ?? 0),
assessment_pending: !!assessment && !passedAssessmentUserIds.has(entry.user_id),
};
}));
+23
View File
@@ -112,6 +112,29 @@ const LESSON_LIST_COMPUTED = [
END
)`,
},
{
// Not its own column — consumed by the frontend's Subscription cell so a
// lesson gated only through an affiliated course (own `subscription` is
// NULL) still shows a tier instead of "-". Distinct tiers across every
// affiliated course, comma-joined (a lesson can reach several courses
// through several units, each possibly at a different tier).
key: "course_subscription",
label: "Course Subscription",
type: "text",
hidden: true,
filterable: false,
literal: `(
SELECT STRING_AGG(sub.subscription, ', ')
FROM (
SELECT DISTINCT c.subscription
FROM unit_lessons ul
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
JOIN course_units cu ON cu.unit_id = u.unit_id
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE ul.lesson_id = "Lesson"."lesson_id"
) sub
)`,
},
];
// ══════════════════════════════════════════════════════════════════════════════
+18 -26
View File
@@ -12,7 +12,6 @@
* 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');
@@ -40,17 +39,6 @@ async function attachImageStreamToken(image, req) {
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) {
try {
@@ -91,21 +79,25 @@ async function unseenCount(req, res) {
// banner for every admin. Whoever dismisses it first dismisses it for all.
async function stickyAnnouncement(req, res) {
try {
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),
]);
const rows = await AdminNotification.findAll({
where: {
seen: false,
show_in_sticky: true,
type: 'announcement',
...notInFutureOrExpired(),
},
include: [IMAGE_INCLUDE],
order: [['createdAt', 'DESC']],
limit: STICKY_LIMIT,
});
return R.success(res, 'Sticky announcements fetched.', { announcements: notifications, bannerImage });
const notifications = await Promise.all(rows.map(async (row) => {
const json = row.toJSON();
if (json.image) await attachImageStreamToken(json.image, req);
return json;
}));
return R.success(res, 'Sticky alerts fetched.', { announcements: notifications });
} catch (err) {
console.error('[NOTIFICATION] stickyAnnouncement error:', err);
return R.error(res, 'Failed to fetch sticky announcement.');
@@ -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);
}
};
+21
View File
@@ -110,6 +110,27 @@ const UNIT_LIST_COMPUTED = [
END
)`,
},
{
// Not its own column — consumed by the frontend's Subscription cell so a
// unit gated only through an affiliated course (own `subscription` is
// NULL) still shows a tier instead of "-". Distinct tiers across every
// affiliated course, comma-joined (a unit can sit in several courses at
// different tiers).
key: "course_subscription",
label: "Course Subscription",
type: "text",
hidden: true,
filterable: false,
literal: `(
SELECT STRING_AGG(sub.subscription, ', ')
FROM (
SELECT DISTINCT c.subscription
FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
WHERE cu.unit_id = "Unit"."unit_id"
) sub
)`,
},
];
// ══════════════════════════════════════════════════════════════════════════════
+64
View File
@@ -705,6 +705,70 @@ exports.unbanUser = async (req, res) => {
}
};
// ─── MAKE ADMIN ───────────────────────────────────────────────────────────────
exports.makeAdmin = async (req, res) => {
try {
const { id } = req.params;
if (Number(id) === req.user.user_id)
return R.error(res, 'You cannot change your own role.', 400);
const user = await mdl_Users.findByPk(id);
if (!user) return R.error(res, 'User not found.', 404);
if (user.acc_type === 'admin')
return R.error(res, 'User is already an administrator.', 400);
await user.update({ acc_type: 'admin', updatedBy: req.user.user_id });
logActivity(req.user.user_id, 'make_admin', { entityType: 'user', entityId: Number(id) });
const fullName = user.personal_info?.name?.full_name ?? 'User';
sendEmail({
to: user.email,
type: 'MADE_ADMIN',
data: { name: fullName.split(' ')[0], email: user.email },
}).catch((err) => console.error('[ADMIN][MAKE ADMIN] Email failed:', err));
return R.success(res, 'User promoted to Administrator.');
} catch (err) {
console.error('[ADMIN][MAKE ADMIN]', err);
return R.error(res, 'Could not update user role.', 500);
}
};
// ─── DEMOTE ADMIN ─────────────────────────────────────────────────────────────
exports.demoteAdmin = async (req, res) => {
try {
const { id } = req.params;
if (Number(id) === req.user.user_id)
return R.error(res, 'You cannot change your own role.', 400);
const user = await mdl_Users.findByPk(id);
if (!user) return R.error(res, 'User not found.', 404);
if (user.acc_type !== 'admin')
return R.error(res, 'User is not an administrator.', 400);
await user.update({ acc_type: 'user', updatedBy: req.user.user_id });
logActivity(req.user.user_id, 'demote_admin', { entityType: 'user', entityId: Number(id) });
const fullName = user.personal_info?.name?.full_name ?? 'User';
sendEmail({
to: user.email,
type: 'DEMOTED_ADMIN',
data: { name: fullName.split(' ')[0], email: user.email },
}).catch((err) => console.error('[ADMIN][DEMOTE ADMIN] Email failed:', err));
return R.success(res, 'Administrator access removed.');
} catch (err) {
console.error('[ADMIN][DEMOTE ADMIN]', err);
return R.error(res, 'Could not update user role.', 500);
}
};
// ─── BULK BAN ─────────────────────────────────────────────────────────────────
exports.bulkBanUsers = async (req, res) => {