mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added and fix some of things
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
|
||||
)`,
|
||||
},
|
||||
];
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
)`,
|
||||
},
|
||||
];
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* Date Created: Jun. 19, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const UserNotification = require('../../models/notifications/user_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');
|
||||
@@ -41,17 +40,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 /client/notifications ────────────────────────────────────────────────
|
||||
async function list(req, res) {
|
||||
try {
|
||||
@@ -94,25 +82,26 @@ async function unseenCount(req, res) {
|
||||
// ─── GET /client/notifications/sticky ─────────────────────────────────────
|
||||
async function stickyAnnouncement(req, res) {
|
||||
try {
|
||||
const [notifications, bannerImage] = await Promise.all([
|
||||
UserNotification.findAll({
|
||||
where: {
|
||||
user_id: req.user.user_id,
|
||||
seen: false,
|
||||
show_in_sticky: true,
|
||||
type: "announcement",
|
||||
...notInFutureOrExpired(),
|
||||
},
|
||||
order: [["createdAt", "DESC"]],
|
||||
limit: STICKY_LIMIT,
|
||||
}),
|
||||
resolveSharedBannerImage(req),
|
||||
]);
|
||||
|
||||
return R.success(res, "Sticky announcements fetched.", {
|
||||
announcements: notifications,
|
||||
bannerImage,
|
||||
const rows = await UserNotification.findAll({
|
||||
where: {
|
||||
user_id: req.user.user_id,
|
||||
seen: false,
|
||||
show_in_sticky: true,
|
||||
type: "announcement",
|
||||
...notInFutureOrExpired(),
|
||||
},
|
||||
include: [IMAGE_INCLUDE],
|
||||
order: [["createdAt", "DESC"]],
|
||||
limit: STICKY_LIMIT,
|
||||
});
|
||||
|
||||
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("[CLIENT NOTIFICATION] stickyAnnouncement error:", err);
|
||||
return R.error(res, "Failed to fetch sticky announcement.");
|
||||
|
||||
Reference in New Issue
Block a user