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
-10
View File
@@ -94,15 +94,5 @@ CHIBISAFE_ALBUM_THUMBNAILS=CHANGE_ME_UUID
CHIBISAFE_ALBUM_DOCUMENTS=CHANGE_ME_UUID CHIBISAFE_ALBUM_DOCUMENTS=CHANGE_ME_UUID
CHIBISAFE_ALBUM_ARCHIVED=CHANGE_ME_UUID CHIBISAFE_ALBUM_ARCHIVED=CHANGE_ME_UUID
# ── MarkItDown (document-import PDF/PPTX -> Markdown sidecar) ────────────────
# Lives in the chibistar/ stack (chibistar/docker-compose.yml), not this
# repo's — `docker compose up -d markitdown` there. Reachable at
# 127.0.0.1:8000 since this app runs natively (npm run dev) during local dev,
# same host.
MARKITDOWN_BASE_URL=http://127.0.0.1:8000
# Required even for local/loopback calls — the sidecar's /convert endpoint
# checks this on every request since it's also reachable publicly.
MARKITDOWN_SHARED_SECRET=CHANGE_ME
# ── CORS ────────────────────────────────────────────────────────────────────── # ── CORS ──────────────────────────────────────────────────────────────────────
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3024 ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3024
-21
View File
@@ -125,27 +125,6 @@ CHIBISAFE_ALBUM_THUMBNAILS=CHANGE_ME_UUID
CHIBISAFE_ALBUM_DOCUMENTS=CHANGE_ME_UUID CHIBISAFE_ALBUM_DOCUMENTS=CHANGE_ME_UUID
CHIBISAFE_ALBUM_ARCHIVED=CHANGE_ME_UUID CHIBISAFE_ALBUM_ARCHIVED=CHANGE_ME_UUID
# ── MarkItDown (document-import PDF/PPTX -> Markdown sidecar) ────────────────
# Lives in the separate chibistar/ stack, not this one — not reachable by
# Docker service name.
#
# Self-hosted via this repo's docker-compose.yml, on the SAME machine as
# chibistar: reach it via host.docker.internal (see docker-compose.yml's
# extra_hosts on `backend`), which resolves to the host running chibistar's
# markitdown container (published to 127.0.0.1:8000 there).
MARKITDOWN_BASE_URL=http://host.docker.internal:8000
#
# Deployed elsewhere (e.g. Render) — no shared network with chibistar, so
# use the public hostname instead (Cloudflare Tunnel, see
# chibistar/docker-compose.yml's markitdown-cloudflared service and
# chibistar/Caddyfile's @markitdown matcher — the zrok share,
# markitdownstarr.share.zrok.io, is kept alongside as a fallback):
# MARKITDOWN_BASE_URL=https://santa-monica.space
#
# Required in both cases — /convert checks this on every request since it's
# reachable publicly, not just over loopback/the Docker network.
MARKITDOWN_SHARED_SECRET=CHANGE_ME
# ── CORS ────────────────────────────────────────────────────────────────────── # ── CORS ──────────────────────────────────────────────────────────────────────
# Comma-separated list of allowed origins. Must match FRONTEND_URL exactly. # Comma-separated list of allowed origins. Must match FRONTEND_URL exactly.
ALLOWED_ORIGINS=https://yourdomain.com ALLOWED_ORIGINS=https://yourdomain.com
@@ -259,6 +259,13 @@ exports.createAdvertisement = async (req, res) => {
try { try {
const advertisement = await Advertisement.build({ placement, type: entry.format, createdBy }); const advertisement = await Advertisement.build({ placement, type: entry.format, createdBy });
await applyAdvertisementFields(advertisement, req.body); 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 advertisement.save({ transaction: t });
await t.commit(); 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) ───────────────────────────────────────────────────────── // ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
exports.archiveAdvertisement = async (req, res) => { exports.archiveAdvertisement = async (req, res) => {
+27 -96
View File
@@ -1,16 +1,15 @@
// controllers/admin/assets.controller.js // controllers/admin/assets.controller.js
const path = require("path"); const path = require("path");
const fs = require("fs");
const sequelize = require("../../config/db.config"); const sequelize = require("../../config/db.config");
const Asset = require("../../models/assets/assets.mdl"); const Asset = require("../../models/assets/assets.mdl");
const chibi = require("../../services/chibisafe.service"); const chibi = require("../../services/chibisafe.service");
const s3 = require("../../services/s3.service"); const s3 = require("../../services/s3.service");
const mediaToken = require("../../services/mediaToken.service"); const mediaToken = require("../../services/mediaToken.service");
const uploadProgress = require("../../services/uploadProgress.service");
const { extractVideoMeta } = require("../../services/ffprobe.service"); const { extractVideoMeta } = require("../../services/ffprobe.service");
const ffmpegSvc = require("../../services/ffmpeg.service"); const ffmpegSvc = require("../../services/ffmpeg.service");
const assetTranscode = require("../../services/assetTranscode.service"); const assetTranscode = require("../../services/assetTranscode.service");
const documentConversion = require("../../services/documentConversion.service");
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util"); const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/assets/assets.attributes"); const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/assets/assets.attributes");
@@ -58,15 +57,6 @@ function resolveExtension(originalName = "") {
return path.extname(originalName).replace(".", "").toLowerCase() || null; 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) { function resolveResolution(width, height) {
if (!width || !height) return null; if (!width || !height) return null;
const h = Math.min(width, height); const h = Math.min(width, height);
@@ -398,7 +388,31 @@ async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, or
video_codec = videoMeta.video_codec; video_codec = videoMeta.video_codec;
audio_codec = videoMeta.audio_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 { } else {
const parsedWidth = body.width ? parseInt(body.width) : null; const parsedWidth = body.width ? parseInt(body.width) : null;
@@ -435,6 +449,7 @@ async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, or
video_codec, video_codec,
audio_codec, audio_codec,
thumbnail_url, thumbnail_url,
thumbnail_storage_key,
description, description,
storage_provider, storage_provider,
storage_bucket: storage_bucket || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null, 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 ─────────────────────────────────────────────────────────────────── // ─── UPDATE ───────────────────────────────────────────────────────────────────
exports.updateAsset = async (req, res) => { exports.updateAsset = async (req, res) => {
+134 -4
View File
@@ -1,15 +1,32 @@
'use strict'; 'use strict';
const { Op } = require('sequelize');
const mdl_Category = require('../../models/courses/categories.mdl'); 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 R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.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) => const slugify = (str) =>
str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
exports.getCategories = async (req, res) => { exports.getCategories = async (req, res) => {
try { try {
const rows = await mdl_Category.findAll({ order: [['name', 'ASC']], paranoid: false }); const archived = req.query.archived === 'true';
return R.success(res, 'Categories retrieved.', rows); 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) { } catch (err) {
console.error('[ADMIN][CATEGORIES][GET ALL]', err); console.error('[ADMIN][CATEGORIES][GET ALL]', err);
return R.error(res, 'Could not retrieve categories.', 500); 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); if (!name) return R.error(res, 'name is required.', 400);
const slug = slugify(name); 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 } }); 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); return R.success(res, 'Category created.', row, 201);
} catch (err) { } catch (err) {
@@ -51,7 +71,10 @@ exports.updateCategory = async (req, res) => {
const { name, description, is_active } = req.body; const { name, description, is_active } = req.body;
const slug = name ? slugify(name) : row.slug; 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 }); logActivity(req.user?.user_id, 'update_category', { entityType: 'category', entityId: row.category_id });
return R.success(res, 'Category updated.', row); return R.success(res, 'Category updated.', row);
} catch (err) { } catch (err) {
@@ -66,6 +89,7 @@ exports.archiveCategory = async (req, res) => {
try { try {
const row = await mdl_Category.findByPk(req.params.id); const row = await mdl_Category.findByPk(req.params.id);
if (!row) return R.error(res, 'Category not found.', 404); 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(); await row.destroy();
logActivity(req.user?.user_id, 'archive_category', { entityType: 'category', entityId: row.category_id }); logActivity(req.user?.user_id, 'archive_category', { entityType: 'category', entityId: row.category_id });
return R.success(res, 'Category archived.'); return R.success(res, 'Category archived.');
@@ -79,7 +103,9 @@ exports.restoreCategory = async (req, res) => {
try { try {
const row = await mdl_Category.findByPk(req.params.id, { paranoid: false }); const row = await mdl_Category.findByPk(req.params.id, { paranoid: false });
if (!row) return R.error(res, 'Category not found.', 404); 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.restore();
await row.update({ deletedBy: null, is_active: true });
logActivity(req.user?.user_id, 'restore_category', { entityType: 'category', entityId: row.category_id }); logActivity(req.user?.user_id, 'restore_category', { entityType: 'category', entityId: row.category_id });
return R.success(res, 'Category restored.', row); return R.success(res, 'Category restored.', row);
} catch (err) { } catch (err) {
@@ -87,3 +113,107 @@ exports.restoreCategory = async (req, res) => {
return R.error(res, 'Could not restore category.', 500); 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 { Op } = require('sequelize');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl'); 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 mdl_Users = require('../../models/users/users.mdl');
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util'); const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
const { resolveAvatarUrl } = require('../../utils/resolveAvatar.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])); 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 // Build per-user summary
const summaryMap = {}; const summaryMap = {};
for (const row of rows) { for (const row of rows) {
@@ -106,6 +145,8 @@ exports.getCourseReadingProgress = async (req, res) => {
lessons_total, lessons_total,
// Fall back to in_progress if the course row hasn't been written yet // Fall back to in_progress if the course row hasn't been written yet
course_status: entry.course_status ?? 'in_progress', 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 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 * Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const AdminNotification = require('../../models/notifications/admin_notification.mdl'); 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 mdl_Assets = require('../../models/assets/assets.mdl');
const mediaToken = require('../../services/mediaToken.service'); const mediaToken = require('../../services/mediaToken.service');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
@@ -40,17 +39,6 @@ async function attachImageStreamToken(image, req) {
return image; 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 ───────────────────────────────────────────────── // ─── GET /admin/notifications ─────────────────────────────────────────────────
async function list(req, res) { async function list(req, res) {
try { try {
@@ -91,21 +79,25 @@ async function unseenCount(req, res) {
// banner for every admin. Whoever dismisses it first dismisses it for all. // banner for every admin. Whoever dismisses it first dismisses it for all.
async function stickyAnnouncement(req, res) { async function stickyAnnouncement(req, res) {
try { try {
const [notifications, bannerImage] = await Promise.all([ const rows = await AdminNotification.findAll({
AdminNotification.findAll({ where: {
where: { seen: false,
seen: false, show_in_sticky: true,
show_in_sticky: true, type: 'announcement',
type: 'announcement', ...notInFutureOrExpired(),
...notInFutureOrExpired(), },
}, include: [IMAGE_INCLUDE],
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
limit: STICKY_LIMIT, limit: STICKY_LIMIT,
}), });
resolveSharedBannerImage(req),
]);
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) { } catch (err) {
console.error('[NOTIFICATION] stickyAnnouncement error:', err); console.error('[NOTIFICATION] stickyAnnouncement error:', err);
return R.error(res, 'Failed to fetch sticky announcement.'); 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 NotificationBroadcast = require("../../models/notifications/notification_broadcast.mdl");
const AdminNotification = require("../../models/notifications/admin_notification.mdl"); const AdminNotification = require("../../models/notifications/admin_notification.mdl");
const UserNotification = require("../../models/notifications/user_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_Users = require('../../models/users/users.mdl');
const mdl_Assets = require('../../models/assets/assets.mdl'); const mdl_Assets = require('../../models/assets/assets.mdl');
const { TaskList } = require('../../models/task/task.mdl'); const { TaskList } = require('../../models/task/task.mdl');
@@ -68,7 +67,33 @@ async function countActiveSticky(excludeId = null) {
} }
const MAX_ACTIVE_STICKY = 3; 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) { async function applyBroadcastFields(broadcast, body) {
if (body.title !== undefined) broadcast.title = body.title; 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_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.link_label !== undefined) broadcast.link_label = body.link_label?.trim() || null;
if (body.color !== undefined) broadcast.color = body.color || 'indigo'; 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.start_date !== undefined) broadcast.start_date = body.start_date || null;
if (body.end_date !== undefined) broadcast.end_date = body.end_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); 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) { } catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ALL]", 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: [ include: [
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" }, { model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" }, { 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(); const json = broadcast.toJSON();
if (json.image) await attachImageStreamToken(json.image, req);
if (json.creator) { if (json.creator) {
json.creator = { json.creator = {
@@ -198,7 +226,7 @@ exports.getBroadcast = async (req, res) => {
await attachTargetLabels(json); await attachTargetLabels(json);
return R.success(res, "Announcement retrieved.", { data: json }); return R.success(res, "Alert retrieved.", { data: json });
} catch (err) { } catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ONE]", err); console.error("[NOTIFICATION BROADCAST][GET ONE]", err);
return R.error(res, "Internal server error.", 500); return R.error(res, "Internal server error.", 500);
@@ -215,6 +243,7 @@ exports.createBroadcast = async (req, res) => {
link_url, link_url,
link_label, link_label,
color, color,
image_asset_id,
target_type, target_type,
target_id, target_id,
createdBy, createdBy,
@@ -243,6 +272,8 @@ exports.createBroadcast = async (req, res) => {
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id); 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(); const t = await sequelize.transaction();
try { try {
const broadcast = await NotificationBroadcast.build({ const broadcast = await NotificationBroadcast.build({
@@ -251,6 +282,7 @@ exports.createBroadcast = async (req, res) => {
link_url: link_url?.trim() || null, link_url: link_url?.trim() || null,
link_label: link_label?.trim() || null, link_label: link_label?.trim() || null,
color: color || 'indigo', color: color || 'indigo',
image_asset_id: validatedImageAssetId,
start_date: start_date || null, start_date: start_date || null,
end_date: end_date || null, end_date: end_date || null,
createdBy, createdBy,
@@ -264,7 +296,7 @@ exports.createBroadcast = async (req, res) => {
await t.commit(); await t.commit();
logActivity(req.user?.user_id, 'create_notification_broadcast', { entityType: 'notification_broadcast', entityId: broadcast.broadcast_id, details: { target_type } }); 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) { } catch (dbErr) {
try { await t.rollback(); } catch { /* connection gone */ } try { await t.rollback(); } catch { /* connection gone */ }
throw dbErr; throw dbErr;
@@ -322,6 +354,7 @@ exports.updateBroadcast = async (req, res) => {
title: broadcast.title, title: broadcast.title,
message: broadcast.message, message: broadcast.message,
color: broadcast.color, color: broadcast.color,
image_asset_id: broadcast.image_asset_id,
show_in_sticky: broadcast.show_in_sticky, show_in_sticky: broadcast.show_in_sticky,
show_in_notifications: broadcast.show_in_notifications, show_in_notifications: broadcast.show_in_notifications,
start_date: broadcast.start_date, start_date: broadcast.start_date,
@@ -334,7 +367,7 @@ exports.updateBroadcast = async (req, res) => {
for (const table of ['admin_notifications', 'user_notifications']) { for (const table of ['admin_notifications', 'user_notifications']) {
await sequelize.query( await sequelize.query(
`UPDATE ${table} `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, show_in_sticky = :show_in_sticky, show_in_notifications = :show_in_notifications,
start_date = :start_date, end_date = :end_date, start_date = :start_date, end_date = :end_date,
data = data || jsonb_build_object('linkUrl', :linkUrl, 'linkLabel', :linkLabel) data = data || jsonb_build_object('linkUrl', :linkUrl, 'linkLabel', :linkLabel)
@@ -347,7 +380,7 @@ exports.updateBroadcast = async (req, res) => {
await t.commit(); await t.commit();
logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); 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) { } catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ } try { await t.rollback(); } catch { /* gone */ }
throw dbErr; throw dbErr;
@@ -367,7 +400,7 @@ exports.sendBroadcast = async (req, res) => {
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400); if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } }); 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.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) { 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') { if (targetType === 'admin' || targetType === 'both') {
await AdminNotification.create( 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 } { transaction: t }
); );
recipientCount += 1; recipientCount += 1;
@@ -437,6 +470,7 @@ exports.sendBroadcast = async (req, res) => {
show_in_sticky: showInSticky, show_in_sticky: showInSticky,
show_in_notifications: showInNotifications, show_in_notifications: showInNotifications,
color: broadcast.color, color: broadcast.color,
image_asset_id: broadcast.image_asset_id,
start_date: broadcast.start_date, start_date: broadcast.start_date,
end_date: broadcast.end_date, end_date: broadcast.end_date,
broadcast_id: broadcast.broadcast_id, broadcast_id: broadcast.broadcast_id,
@@ -453,7 +487,7 @@ exports.sendBroadcast = async (req, res) => {
await t.commit(); 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 } }); 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) { } catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ } try { await t.rollback(); } catch { /* gone */ }
throw dbErr; throw dbErr;
@@ -473,12 +507,25 @@ exports.archiveBroadcast = async (req, res) => {
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400); if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } }); 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) }); 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) { } catch (err) {
console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err); console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err);
return R.error(res, "Internal server error.", 500); 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); const activeIds = broadcasts.map((b) => b.broadcast_id);
await NotificationBroadcast.update({ deletedBy: deletedBy ?? null }, { where: { broadcast_id: { [Op.in]: activeIds } } }); const t = await sequelize.transaction();
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: activeIds } } }); 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 } }); 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.`, { return R.success(res, `${activeIds.length} notification broadcast(s) archived.`, {
@@ -518,13 +577,30 @@ exports.restoreBroadcast = async (req, res) => {
const { broadcastId } = req.params; const { broadcastId } = req.params;
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false }); const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
if (!broadcast) return R.error(res, "Announcement not found.", 404); if (!broadcast) return R.error(res, "Alert not found.", 404);
if (!broadcast.deletedAt) return R.error(res, "Announcement is not archived.", 400); 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) }); 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) { } catch (err) {
console.error("[NOTIFICATION BROADCAST][RESTORE]", err); console.error("[NOTIFICATION BROADCAST][RESTORE]", err);
return R.error(res, "Internal server error.", 500); 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); const archivedIds = archived.map((b) => b.broadcast_id);
await NotificationBroadcast.restore({ where: { broadcast_id: { [Op.in]: archivedIds } } }); const t = await sequelize.transaction();
await NotificationBroadcast.update({ deletedBy: null }, { where: { broadcast_id: { [Op.in]: archivedIds } }, paranoid: false }); 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 } }); 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.`, { return R.success(res, `${archivedIds.length} notification broadcast(s) restored.`, {
@@ -572,10 +667,10 @@ exports.getArchivedBroadcasts = async (req, res) => {
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' }, auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } }, 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) { } catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ARCHIVED]", 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 { broadcastId } = req.params;
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false }); const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
if (!broadcast) return R.error(res, "Announcement not found.", 404); if (!broadcast) return R.error(res, "Alert not found.", 404);
if (!broadcast.deletedAt) return R.error(res, "Announcement must be archived before it can be permanently deleted.", 400); if (!broadcast.deletedAt) return R.error(res, "Alert must be archived before it can be permanently deleted.", 400);
await broadcast.destroy({ force: true }); await broadcast.destroy({ force: true });
logActivity(req.user?.user_id, 'permanently_delete_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) }); 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) { } catch (err) {
console.error("[NOTIFICATION BROADCAST][PERMANENT DELETE]", 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); 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 }); 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); 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); const archivedIds = archived.map((b) => b.broadcast_id);
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: archivedIds } }, force: true }); 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 } }); 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, deleted_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)), skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
}); });
} catch (err) { } catch (err) {
console.error("[NOTIFICATION BROADCAST][BULK PERMANENT DELETE]", 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 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 ───────────────────────────────────────────────────────────────── // ─── BULK BAN ─────────────────────────────────────────────────────────────────
exports.bulkBanUsers = async (req, res) => { exports.bulkBanUsers = async (req, res) => {
+19 -30
View File
@@ -12,7 +12,6 @@
* Date Created: Jun. 19, 2026 * Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const UserNotification = require('../../models/notifications/user_notification.mdl'); 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 mdl_Assets = require('../../models/assets/assets.mdl');
const mediaToken = require('../../services/mediaToken.service'); const mediaToken = require('../../services/mediaToken.service');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
@@ -41,17 +40,6 @@ async function attachImageStreamToken(image, req) {
return image; 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 ──────────────────────────────────────────────── // ─── GET /client/notifications ────────────────────────────────────────────────
async function list(req, res) { async function list(req, res) {
try { try {
@@ -94,25 +82,26 @@ async function unseenCount(req, res) {
// ─── GET /client/notifications/sticky ───────────────────────────────────── // ─── GET /client/notifications/sticky ─────────────────────────────────────
async function stickyAnnouncement(req, res) { async function stickyAnnouncement(req, res) {
try { try {
const [notifications, bannerImage] = await Promise.all([ const rows = await UserNotification.findAll({
UserNotification.findAll({ where: {
where: { user_id: req.user.user_id,
user_id: req.user.user_id, seen: false,
seen: false, show_in_sticky: true,
show_in_sticky: true, type: "announcement",
type: "announcement", ...notInFutureOrExpired(),
...notInFutureOrExpired(), },
}, include: [IMAGE_INCLUDE],
order: [["createdAt", "DESC"]], order: [["createdAt", "DESC"]],
limit: STICKY_LIMIT, limit: STICKY_LIMIT,
}),
resolveSharedBannerImage(req),
]);
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) { } catch (err) {
console.error("[CLIENT NOTIFICATION] stickyAnnouncement error:", err); console.error("[CLIENT NOTIFICATION] stickyAnnouncement error:", err);
return R.error(res, "Failed to fetch sticky announcement."); return R.error(res, "Failed to fetch sticky announcement.");
+29
View File
@@ -118,6 +118,35 @@ const emailTemplates = {
`), `),
}), }),
MADE_ADMIN: ({ name, email }) => ({
subject: "You've Been Granted Administrator Access - STARR System",
html: wrap(`
<p>Dear ${name},</p>
<p><strong>Congratulations!</strong></p>
<p>We're pleased to inform you that your account has been granted Administrator access to the platform.</p>
<p>As an Administrator, you now have access to management features that allow you to oversee users, courses, content, and other administrative functions.</p>
<table style="${FONT}">
<tr><td><strong>Email</strong></td><td>${email}</td></tr>
<tr><td><strong>Role</strong></td><td>Administrator</td></tr>
</table>
<p>Please sign in using your existing credentials. If you have any questions or require assistance, don't hesitate to contact our support team.</p>
`),
}),
DEMOTED_ADMIN: ({ name, email }) => ({
subject: "Your Account Role Has Been Updated - STARR System",
html: wrap(`
<p>Dear ${name},</p>
<p>This email is to inform you that your account has been updated.</p>
<p>Your Administrator privileges have been removed, and your account has been returned to a User role.</p>
<table style="${FONT}">
<tr><td><strong>Email</strong></td><td>${email}</td></tr>
<tr><td><strong>Current Role</strong></td><td>User</td></tr>
</table>
<p>You can continue using the platform with the permissions available to standard users. If you believe this change was made in error or have any questions, please contact your system administrator.</p>
`),
}),
ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({ ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({
subject: "Your Staff Account Has Been Created - STARR System", subject: "Your Staff Account Has Been Created - STARR System",
html: wrap(` html: wrap(`
@@ -0,0 +1,15 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('categories', 'createdBy', { type: Sequelize.BIGINT, allowNull: true });
await queryInterface.addColumn('categories', 'updatedBy', { type: Sequelize.BIGINT, allowNull: true });
await queryInterface.addColumn('categories', 'deletedBy', { type: Sequelize.BIGINT, allowNull: true });
},
async down(queryInterface) {
await queryInterface.removeColumn('categories', 'createdBy');
await queryInterface.removeColumn('categories', 'updatedBy');
await queryInterface.removeColumn('categories', 'deletedBy');
},
};
@@ -0,0 +1,27 @@
'use strict';
// Per-alert layout image, replacing the old shared sticky_banner_settings
// singleton (see 20270101000066-create-sticky-banner-settings.js) — each
// notification_broadcasts row now carries its own optional image, copied
// onto admin_notifications/user_notifications at send time the same way
// color/show_in_sticky already are.
const TABLES = ['notification_broadcasts', 'admin_notifications', 'user_notifications'];
module.exports = {
async up(queryInterface, Sequelize) {
for (const table of TABLES) {
await queryInterface.addColumn(table, 'image_asset_id', {
type: Sequelize.BIGINT,
allowNull: true,
references: { model: 'assets', key: 'asset_id' },
onDelete: 'SET NULL',
});
}
},
async down(queryInterface) {
for (const table of TABLES) {
await queryInterface.removeColumn(table, 'image_asset_id');
}
},
};
-6
View File
@@ -29,17 +29,11 @@
services: services:
# ── Backend (Express) ─────────────────────────────────────────────────────── # ── Backend (Express) ───────────────────────────────────────────────────────
# MarkItDown (PDF/PPTX -> Markdown sidecar for document import) lives in the
# separate chibistar/ stack, not here — extra_hosts lets this container
# reach its host-published port via host.docker.internal. Set
# MARKITDOWN_BASE_URL=http://host.docker.internal:8000 in .env for this to work.
backend: backend:
build: . build: .
ports: ports:
- "3024:3024" - "3024:3024"
env_file: .env env_file: .env
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
+6
View File
@@ -0,0 +1,6 @@
'use strict';
const excludeAttributes = ['description'];
const jsonbSchemas = {};
module.exports = { excludeAttributes, jsonbSchemas };
+5 -2
View File
@@ -3,11 +3,14 @@ const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config'); const sequelize = require('../../config/db.config');
const mdl_Category = sequelize.define('Category', { const mdl_Category = sequelize.define('Category', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true },
name: { type: DataTypes.STRING(100), allowNull: false, unique: true }, name: { type: DataTypes.STRING(100), allowNull: false, unique: true },
slug: { type: DataTypes.STRING(120), allowNull: false, unique: true }, slug: { type: DataTypes.STRING(120), allowNull: false, unique: true },
description: { type: DataTypes.TEXT, allowNull: true }, description: { type: DataTypes.TEXT, allowNull: true },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Status" },
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
}, { }, {
tableName: 'categories', tableName: 'categories',
timestamps: true, timestamps: true,
@@ -10,6 +10,7 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config'); const sequelize = require('../../config/db.config');
const mdl_Assets = require('../assets/assets.mdl');
const AdminNotification = sequelize.define('AdminNotification', { const AdminNotification = sequelize.define('AdminNotification', {
notification_id: { notification_id: {
@@ -49,6 +50,14 @@ const AdminNotification = sequelize.define('AdminNotification', {
defaultValue: 'indigo', defaultValue: 'indigo',
}, },
// Denormalized copy of the source NotificationBroadcast's per-alert
// layout image (see notification_broadcast.mdl.js) — null when the
// alert has none.
image_asset_id: {
type: DataTypes.BIGINT,
allowNull: true,
},
// Denormalized copy of the source NotificationBroadcast's visibility // Denormalized copy of the source NotificationBroadcast's visibility
// window (see notificationVisibility.util.js) — null means unbounded. // window (see notificationVisibility.util.js) — null means unbounded.
start_date: { start_date: {
@@ -94,4 +103,6 @@ const AdminNotification = sequelize.define('AdminNotification', {
], ],
}); });
AdminNotification.belongsTo(mdl_Assets, { as: "image", foreignKey: "image_asset_id" });
module.exports = AdminNotification; module.exports = AdminNotification;
@@ -2,6 +2,7 @@
const { DataTypes } = require("sequelize"); const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config"); const sequelize = require("../../config/db.config");
const mdl_Users = require("../users/users.mdl"); const mdl_Users = require("../users/users.mdl");
const mdl_Assets = require("../assets/assets.mdl");
const NotificationBroadcast = sequelize.define("NotificationBroadcast", { const NotificationBroadcast = sequelize.define("NotificationBroadcast", {
@@ -23,6 +24,11 @@ const NotificationBroadcast = sequelize.define("NotificationBroadcast", {
// sticky banner's panel background/border — same registry rewards/tiers use. // sticky banner's panel background/border — same registry rewards/tiers use.
color: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'indigo', label: "Color", order: 2.4 }, color: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'indigo', label: "Color", order: 2.4 },
// Optional per-alert image shown in the click-through details dialog when
// opened from the sticky banner (see AnnouncementCarouselDialog on the
// frontend). Replaces the old shared sticky_banner_settings singleton.
image_asset_id: { type: DataTypes.BIGINT, allowNull: true, label: "Layout Image", order: 2.45 },
// Optional visibility window. Null start_date = show immediately once sent; // Optional visibility window. Null start_date = show immediately once sent;
// null end_date = show indefinitely. Copied onto AdminNotification/ // null end_date = show indefinitely. Copied onto AdminNotification/
// UserNotification rows at send time so client reads can filter without // UserNotification rows at send time so client reads can filter without
@@ -34,7 +40,7 @@ const NotificationBroadcast = sequelize.define("NotificationBroadcast", {
// Determines where a delivered announcement shows up for recipients. // Determines where a delivered announcement shows up for recipients.
// - show_in_sticky: client sticky banner (fixed top) // - show_in_sticky: client sticky banner (fixed top)
// - show_in_notifications: client + admin notifications lists/bells // - show_in_notifications: client + admin notifications lists/bells
show_in_sticky: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: "Show in Sticky Announcements", order: 2.5 }, show_in_sticky: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: "Show in Sticky Alerts", order: 2.5 },
show_in_notifications: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Show in Notifications", order: 2.6 }, show_in_notifications: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Show in Notifications", order: 2.6 },
// ─── Targeting ──────────────────────────────────────────────────────────── // ─── Targeting ────────────────────────────────────────────────────────────
@@ -74,5 +80,6 @@ const NotificationBroadcast = sequelize.define("NotificationBroadcast", {
NotificationBroadcast.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); NotificationBroadcast.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
NotificationBroadcast.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); NotificationBroadcast.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
NotificationBroadcast.belongsTo(mdl_Assets, { as: "image", foreignKey: "image_asset_id" });
module.exports = NotificationBroadcast; module.exports = NotificationBroadcast;
@@ -1,22 +0,0 @@
// models/notifications/sticky_banner_setting.mdl.js
//
// Singleton (always id=1) — one shared banner image shown alongside every
// currently-active sticky announcement, not one image per announcement. Set
// from the Announcements list page (see admin/notificationBroadcasts.controller.js's
// getStickyBannerSetting/updateStickyBannerSetting).
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const mdl_Assets = require("../assets/assets.mdl");
const StickyBannerSetting = sequelize.define("StickyBannerSetting", {
id: { type: DataTypes.SMALLINT, primaryKey: true, defaultValue: 1 },
image_asset_id: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
}, {
tableName: "sticky_banner_settings",
timestamps: true,
});
StickyBannerSetting.belongsTo(mdl_Assets, { as: "image", foreignKey: "image_asset_id" });
module.exports = StickyBannerSetting;
@@ -10,6 +10,7 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize'); const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config'); const sequelize = require('../../config/db.config');
const mdl_Assets = require('../assets/assets.mdl');
const UserNotification = sequelize.define('UserNotification', { const UserNotification = sequelize.define('UserNotification', {
notification_id: { notification_id: {
@@ -55,6 +56,14 @@ const UserNotification = sequelize.define('UserNotification', {
defaultValue: 'indigo', defaultValue: 'indigo',
}, },
// Denormalized copy of the source NotificationBroadcast's per-alert
// layout image (see notification_broadcast.mdl.js) — null when the
// alert has none.
image_asset_id: {
type: DataTypes.BIGINT,
allowNull: true,
},
// Denormalized copy of the source NotificationBroadcast's visibility // Denormalized copy of the source NotificationBroadcast's visibility
// window (see notificationVisibility.util.js) — null means unbounded. // window (see notificationVisibility.util.js) — null means unbounded.
start_date: { start_date: {
@@ -101,4 +110,6 @@ const UserNotification = sequelize.define('UserNotification', {
], ],
}); });
UserNotification.belongsTo(mdl_Assets, { as: "image", foreignKey: "image_asset_id" });
module.exports = UserNotification; module.exports = UserNotification;
+1
View File
@@ -17,6 +17,7 @@ router.post('/', controller.createAdvertisement);
// ─── Dynamic routes last ────────────────────────────────────────────────────── // ─── Dynamic routes last ──────────────────────────────────────────────────────
router.get('/:advertisementId', controller.getAdvertisement); router.get('/:advertisementId', controller.getAdvertisement);
router.patch('/:advertisementId', sensitiveOpsLimiter, controller.updateAdvertisement); router.patch('/:advertisementId', sensitiveOpsLimiter, controller.updateAdvertisement);
router.patch('/:advertisementId/reorder', sensitiveOpsLimiter, controller.reorderAdvertisement);
router.patch('/:advertisementId/restore', sensitiveOpsLimiter, controller.restoreAdvertisement); router.patch('/:advertisementId/restore', sensitiveOpsLimiter, controller.restoreAdvertisement);
router.delete('/:advertisementId', sensitiveOpsLimiter, controller.archiveAdvertisement); router.delete('/:advertisementId', sensitiveOpsLimiter, controller.archiveAdvertisement);
router.delete('/:advertisementId/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteAdvertisement); router.delete('/:advertisementId/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteAdvertisement);
-2
View File
@@ -10,7 +10,6 @@ router.get('/field-values', controller.getAssetFieldValues);
router.delete('/bulk', sensitiveOpsLimiter, controller.archiveAssets); router.delete('/bulk', sensitiveOpsLimiter, controller.archiveAssets);
router.delete('/bulk/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteAssets); router.delete('/bulk/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteAssets);
router.patch('/bulk-restore', controller.restoreAssets); router.patch('/bulk-restore', controller.restoreAssets);
router.get('/convert-progress/:jobId', controller.streamConvertProgress);
// ─── Collection ─────────────────────────────────────────────────────────────── // ─── Collection ───────────────────────────────────────────────────────────────
router.get('/', controller.getAssets); router.get('/', controller.getAssets);
@@ -27,7 +26,6 @@ router.post('/', controller.uploadAsset);
// ─── Dynamic routes last ────────────────────────────────────────────────────── // ─── Dynamic routes last ──────────────────────────────────────────────────────
router.get('/:assetId', controller.getAsset); router.get('/:assetId', controller.getAsset);
router.post('/:assetId/convert-to-markdown', sensitiveOpsLimiter, controller.convertAssetToMarkdown);
// Replace flow also goes through presign + direct PUT (browser -> storage) — // Replace flow also goes through presign + direct PUT (browser -> storage) —
// this body is plain JSON (storage_key etc.), same as POST /. No multer here. // this body is plain JSON (storage_key etc.), same as POST /. No multer here.
router.patch('/:assetId', sensitiveOpsLimiter, controller.updateAsset); router.patch('/:assetId', sensitiveOpsLimiter, controller.updateAsset);
+11 -6
View File
@@ -2,11 +2,16 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const ctrl = require('../../controllers/admin/categories.controller'); const ctrl = require('../../controllers/admin/categories.controller');
router.get ('/', ctrl.getCategories); router.get ('/', ctrl.getCategories);
router.post ('/', ctrl.createCategory); router.post ('/', ctrl.createCategory);
router.get ('/:id', ctrl.getCategory); router.delete('/bulk', ctrl.bulkArchiveCategories);
router.put ('/:id', ctrl.updateCategory); router.post ('/bulk-restore', ctrl.bulkRestoreCategories);
router.delete('/:id', ctrl.archiveCategory); router.delete('/bulk/permanent', ctrl.bulkPermanentlyDeleteCategories);
router.post ('/:id/restore', ctrl.restoreCategory); router.get ('/:id/permanent-delete-impact', ctrl.getCategoryPermanentDeleteImpact);
router.get ('/:id', ctrl.getCategory);
router.put ('/:id', ctrl.updateCategory);
router.delete('/:id', ctrl.archiveCategory);
router.post ('/:id/restore', ctrl.restoreCategory);
router.delete('/:id/permanent', ctrl.permanentlyDeleteCategory);
module.exports = router; module.exports = router;
@@ -8,8 +8,6 @@ router.get('/archived', controller.getArchivedBroadcasts);
router.delete('/bulk', sensitiveOpsLimiter, controller.archiveBroadcasts); router.delete('/bulk', sensitiveOpsLimiter, controller.archiveBroadcasts);
router.patch('/bulk-restore', controller.restoreBroadcasts); router.patch('/bulk-restore', controller.restoreBroadcasts);
router.delete('/bulk/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteBroadcasts); router.delete('/bulk/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteBroadcasts);
router.get('/sticky-banner', controller.getStickyBannerSetting);
router.patch('/sticky-banner', sensitiveOpsLimiter, controller.updateStickyBannerSetting);
// ─── Collection ─────────────────────────────────────────────────────────────── // ─── Collection ───────────────────────────────────────────────────────────────
router.get('/', controller.getBroadcasts); router.get('/', controller.getBroadcasts);
+2
View File
@@ -17,6 +17,8 @@ router.put('/:id', sensitiveOpsLimiter, usersCtrl.updateUser);
router.delete('/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser); router.delete('/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser);
router.delete('/:id/permanent', sensitiveOpsLimiter, usersCtrl.permanentlyDeleteUser); router.delete('/:id/permanent', sensitiveOpsLimiter, usersCtrl.permanentlyDeleteUser);
router.post('/:id/restore', sensitiveOpsLimiter, usersCtrl.restoreUser); router.post('/:id/restore', sensitiveOpsLimiter, usersCtrl.restoreUser);
router.post('/:id/make-admin', sensitiveOpsLimiter, usersCtrl.makeAdmin);
router.post('/:id/demote-admin', sensitiveOpsLimiter, usersCtrl.demoteAdmin);
// ── Bans ────────────────────────────────────────────────────────────────────── // ── Bans ──────────────────────────────────────────────────────────────────────
router.post('/bulk/ban', sensitiveOpsLimiter, usersCtrl.bulkBanUsers); router.post('/bulk/ban', sensitiveOpsLimiter, usersCtrl.bulkBanUsers);
-135
View File
@@ -1,135 +0,0 @@
// services/documentConversion.service.js
//
// PDF/PPTX -> Markdown via the MarkItDown sidecar (chibistar/markitdown-service),
// which does the actual parsing + Markdown generation. That's why this file no
// longer builds or walks an AST like the old officeparser-based version did —
// the sidecar already returns a Markdown string, so there's nothing left to
// serialize on this side.
//
// Three named stages, called in sequence by the controller (unchanged from
// before this swap — the controller and frontend don't know or care that the
// extraction backend changed):
// compile(buffer, extension) -> call the sidecar, get back markdown + a text length
// validate(result) -> confirm there's usable text before generating anything
// automate(result) -> light cleanup (frontmatter strip) of the sidecar's markdown
//
// Environment variables expected:
// MARKITDOWN_BASE_URL — base URL of the markitdown-service sidecar (see chibistar/docker-compose.yml)
// MARKITDOWN_SHARED_SECRET — sent as X-Shared-Secret; the sidecar is reachable
// publicly (markitdown.starr.philpro.orij.space), not
// just over loopback/the Docker network, so this is
// required in every environment, dev included.
const axios = require("axios");
const MIN_EXTRACTED_TEXT_LENGTH = 20;
// Backstop only — the controller's own AbortSignal (tied to its
// CONVERT_TIMEOUT_MS) is what actually bounds this request in practice. This
// exists so a hung sidecar can't wedge the request open indefinitely if that
// signal somehow never fires. MarkItDown does plain CPU-bound parsing (no ML
// inference), so this is a modest timeout, not the generous one a
// Docling-backed sidecar would need.
const MARKITDOWN_REQUEST_TIMEOUT_MS = 45_000;
const BASE_URL = (process.env.MARKITDOWN_BASE_URL || "").replace(/\/$/, "");
const SHARED_SECRET = process.env.MARKITDOWN_SHARED_SECRET || "";
const markitdown = axios.create({
baseURL: BASE_URL,
timeout: MARKITDOWN_REQUEST_TIMEOUT_MS,
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
// ─── compile ───────────────────────────────────────────────────────────────
//
// PPTX is the more failure-prone input than PDF — OOXML slide decks exported
// by different PowerPoint/export-pipeline versions vary more than PDFs do —
// so PPTX failures get an explicit, format-aware message instead of a raw
// parser error. Same convention the sidecar itself follows for its own 422s.
//
async function compile(buffer, extension, { signal } = {}) {
const fileType = (extension || "").toLowerCase();
if (!BASE_URL) {
throw Object.assign(new Error("Document conversion is not configured (MARKITDOWN_BASE_URL missing)."), { status: 500, stage: "compile" });
}
if (!SHARED_SECRET) {
throw Object.assign(new Error("Document conversion is not configured (MARKITDOWN_SHARED_SECRET missing)."), { status: 500, stage: "compile" });
}
try {
const res = await markitdown.post("/convert", buffer, {
headers: {
"Content-Type": "application/octet-stream",
"X-File-Extension": fileType,
"X-Shared-Secret": SHARED_SECRET,
},
signal,
});
return res.data; // { markdown, textLength, warnings }
} catch (err) {
// The sidecar reached us and rejected the file (422 bad/corrupt doc, 503
// over its concurrency limit, etc.) — surface its own message as-is.
if (err.response) {
throw Object.assign(new Error(err.response.data?.detail || "Document conversion failed."), {
status: err.response.status,
stage: "compile",
cause: err,
});
}
// The sidecar never responded at all (down, unreachable, or the
// controller's/our own timeout fired) — fall back to a friendly,
// format-aware message since there's no sidecar-provided one to use.
const friendly = fileType === "pptx"
? "Couldn't read this PowerPoint file. It may be corrupted, password-protected, or saved in a format this parser doesn't support — try re-exporting it from PowerPoint."
: "Couldn't read this PDF. It may be corrupted, password-protected, or scanned/image-only with no embedded text.";
const timedOut = axios.isCancel(err) || err.code === "ECONNABORTED";
throw Object.assign(new Error(friendly), { status: timedOut ? 504 : 502, stage: "compile", cause: err });
}
}
// ─── validate ──────────────────────────────────────────────────────────────
//
// Confirms the sidecar actually found usable text before anything is
// generated. A document with no extractable text (e.g. a slide deck that's
// entirely images/screenshots) is a hard stop here — OCR is out of scope, so
// there's no fallback to offer, and drafting an empty block would just be
// confusing. The length itself is computed sidecar-side — nothing here
// re-walks a tree anymore.
//
function validate(result) {
const warnings = result.warnings || [];
const extractedLength = result.textLength || 0;
if (extractedLength < MIN_EXTRACTED_TEXT_LENGTH) {
throw Object.assign(
new Error("No readable text found in this document. This tool only extracts text (including hyperlinks) — image-only or scanned documents aren't supported."),
{ status: 422, stage: "validate" },
);
}
return { warnings, stats: { extractedLength } };
}
// ─── automate ──────────────────────────────────────────────────────────────
//
// MarkItDown already emits Markdown — this is just the same
// frontmatter-stripping cleanup the officeparser path used to need, kept
// as-is since some exporters still prepend a properties block.
//
function stripFrontmatter(markdown) {
return markdown.replace(/^---\n[\s\S]*?\n---\n+/, "");
}
async function automate(result) {
return {
markdown: stripFrontmatter(result.markdown || "").trim(),
messages: result.warnings || [],
};
}
module.exports = { compile, validate, automate };
+49 -1
View File
@@ -99,4 +99,52 @@ function remuxToFaststartMp4(inputUrl, { timeoutMs = 30 * 60 * 1000 } = {}) {
}); });
} }
module.exports = { needsRemux, remuxToFaststartMp4 }; // Grabs a single frame from a video as a JPEG thumbnail — used when a video
// asset lands with no client-provided thumbnail (see finalizeAssetFromStorage
// in assets.controller.js). Unlike remuxToFaststartMp4 above, this is cheap
// enough (one frame, not a full re-encode/copy) to run inline during the
// upload-finalize request rather than as a background job.
//
// input: presigned GET URL for the video, + its duration (seconds, from
// ffprobe) to pick a safe seek point.
// output: local filesystem path to the extracted .jpg (caller owns cleanup).
function extractFrameThumbnail(inputUrl, duration, { timeoutMs = 30 * 1000 } = {}) {
// Seek 1s in, or the midpoint for clips shorter than ~2s — avoids grabbing
// frame 0, which is often black/blank on screen recordings and slates.
const atSeconds = !duration || duration <= 0 ? 0 : Math.min(1, duration / 2);
const outputPath = path.join(os.tmpdir(), `thumb_${Date.now()}_${crypto.randomBytes(4).toString("hex")}.jpg`);
return new Promise((resolve, reject) => {
let settled = false;
const command = ffmpeg(inputUrl)
.seekInput(atSeconds) // input-side seek — fast, keyframe-based
.outputOptions(["-frames:v 1", "-q:v 2"]);
const timer = setTimeout(() => {
if (settled) return;
settled = true;
command.kill("SIGKILL");
fs.promises.unlink(outputPath).catch(() => {});
reject(new Error(`Thumbnail extraction timed out after ${timeoutMs}ms`));
}, timeoutMs);
command
.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fs.promises.unlink(outputPath).catch(() => {});
reject(err);
})
.on("end", () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(outputPath);
})
.save(outputPath);
});
}
module.exports = { needsRemux, remuxToFaststartMp4, extractFrameThumbnail };
-52
View File
@@ -1,52 +0,0 @@
/***********************************************************************************************************************************************************************
* File Name: uploadProgress.service.js
* Type of Program: Service
* Description: In-memory SSE broadcaster for real upload progress on the
* Express -> S3 (Garage) leg of an asset upload. Keyed by a
* client-generated uploadId so the browser can open the stream
* before the upload request itself is even sent.
*
* No Redis — single-process only, same tradeoff already made by
* mediaToken.service.js's in-memory token cache. Fine for one
* instance; a second app instance would just never see progress
* for uploads routed to the other process.
*
* Author: Kenneth Obsequio (@lash0000)
***********************************************************************************************************************************************************************/
"use strict";
const clients = new Map(); // uploadId -> Response
function subscribe(uploadId, res) {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // disable proxy-side buffering (nginx-style intermediaries)
});
res.write(": connected\n\n");
clients.set(uploadId, res);
res.on("close", () => {
if (clients.get(uploadId) === res) clients.delete(uploadId);
});
}
// No-ops if nobody's subscribed (client never opened the stream, or already
// disconnected) — progress is a best-effort visual, never load-bearing for
// the actual upload.
function publish(uploadId, data) {
const res = clients.get(uploadId);
if (!res) return;
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
function complete(uploadId, data = {}) {
const res = clients.get(uploadId);
if (!res) return;
res.write(`data: ${JSON.stringify({ ...data, done: true })}\n\n`);
res.end();
clients.delete(uploadId);
}
module.exports = { subscribe, publish, complete };
+13
View File
@@ -2,6 +2,7 @@ const { Op } = require("sequelize");
const sequelize = require("../config/db.config"); const sequelize = require("../config/db.config");
const Sequelize = require("sequelize"); const Sequelize = require("sequelize");
const R = require("../utils/response.util"); // adjust path as needed const R = require("../utils/response.util"); // adjust path as needed
const mdl_TierCategories = require("../models/tiers/tier_categories.mdl");
const auditByFields = ["createdBy", "updatedBy", "deletedBy"]; const auditByFields = ["createdBy", "updatedBy", "deletedBy"];
@@ -42,6 +43,18 @@ const getFieldValues = (Model, logTag, options = {}) => async (req, res) => {
return R.success(res, "Field values retrieved.", ["true", "false"]); return R.success(res, "Field values retrieved.", ["true", "false"]);
} }
// ─── Tier gate fields — same reasoning as ENUM above: the picklist should
// reflect every tier category that exists, not just whichever ones a
// DISTINCT scan happens to find already assigned to a row.
if (field === "subscription") {
const categories = await mdl_TierCategories.findAll({
where: { is_active: true },
order: [["rank", "ASC"]],
raw: true,
});
return R.success(res, "Field values retrieved.", categories.map((c) => ({ value: c.slug, label: c.name })));
}
if (auditByFields.includes(field)) { if (auditByFields.includes(field)) {
// Filtering must match the audit column's real (bigint) id — returning // Filtering must match the audit column's real (bigint) id — returning
// just the display name here previously made buildWhere() compare a // just the display name here previously made buildWhere() compare a