mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -0,0 +1,126 @@
|
||||
'use strict';
|
||||
|
||||
const mdl_AchievementDefinitions = require('../../models/users/achievement_definitions.mdl');
|
||||
const CourseAchievement = require('../../models/courses/course_achievement.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
const VALID_TYPES = ['badge', 'milestone'];
|
||||
|
||||
// ─── GET /admin/achievements ──────────────────────────────────────────────────
|
||||
|
||||
exports.getAchievements = async (req, res) => {
|
||||
try {
|
||||
const achievements = await mdl_AchievementDefinitions.findAll({
|
||||
order: [['createdAt', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Achievements retrieved.', achievements);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ACHIEVEMENTS]', err);
|
||||
return R.error(res, 'Could not retrieve achievements.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /admin/achievements/:id ──────────────────────────────────────────────
|
||||
|
||||
exports.getAchievement = async (req, res) => {
|
||||
try {
|
||||
const achievement = await mdl_AchievementDefinitions.findByPk(req.params.id);
|
||||
if (!achievement) return R.error(res, 'Achievement not found.', 404);
|
||||
return R.success(res, 'Achievement retrieved.', achievement);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ACHIEVEMENT]', err);
|
||||
return R.error(res, 'Could not retrieve achievement.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/achievements ─────────────────────────────────────────────────
|
||||
|
||||
exports.createAchievement = async (req, res) => {
|
||||
try {
|
||||
const { key, type, label, description, icon, trigger, is_active } = req.body;
|
||||
if (!key || !label) return R.error(res, 'key and label are required.', 400);
|
||||
if (type && !VALID_TYPES.includes(type)) return R.error(res, `type must be one of: ${VALID_TYPES.join(', ')}.`, 400);
|
||||
|
||||
const exists = await mdl_AchievementDefinitions.findOne({ where: { key } });
|
||||
if (exists) return R.error(res, `An achievement with key "${key}" already exists.`, 409);
|
||||
|
||||
const achievement = await mdl_AchievementDefinitions.create({
|
||||
key,
|
||||
type: type || 'badge',
|
||||
label,
|
||||
description: description ?? null,
|
||||
icon: icon || null,
|
||||
trigger: trigger || null,
|
||||
is_active: is_active !== undefined ? !!is_active : true,
|
||||
is_system: false, // only seed data may be system-protected
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'create_achievement', { entityType: 'achievement', details: { key, label } });
|
||||
|
||||
return R.success(res, 'Achievement created.', achievement, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE ACHIEVEMENT]', err);
|
||||
return R.error(res, 'Could not create achievement.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT /admin/achievements/:id ──────────────────────────────────────────────
|
||||
|
||||
exports.updateAchievement = async (req, res) => {
|
||||
try {
|
||||
const achievement = await mdl_AchievementDefinitions.findByPk(req.params.id);
|
||||
if (!achievement) return R.error(res, 'Achievement not found.', 404);
|
||||
|
||||
const { key, type, label, description, icon, trigger, is_active } = req.body;
|
||||
|
||||
if (achievement.is_system && key !== undefined && key !== achievement.key)
|
||||
return R.error(res, 'The key of a system achievement cannot be changed.', 400);
|
||||
if (achievement.is_system && type !== undefined && type !== achievement.type)
|
||||
return R.error(res, 'The type of a system achievement cannot be changed.', 400);
|
||||
if (type && !VALID_TYPES.includes(type)) return R.error(res, `type must be one of: ${VALID_TYPES.join(', ')}.`, 400);
|
||||
|
||||
if (!achievement.is_system && key !== undefined && key !== achievement.key) {
|
||||
const exists = await mdl_AchievementDefinitions.findOne({ where: { key } });
|
||||
if (exists) return R.error(res, `An achievement with key "${key}" already exists.`, 409);
|
||||
}
|
||||
|
||||
await achievement.update({
|
||||
key: (!achievement.is_system && key !== undefined) ? key : achievement.key,
|
||||
type: (!achievement.is_system && type !== undefined) ? type : achievement.type,
|
||||
label: label ?? achievement.label,
|
||||
description: description !== undefined ? (description || null) : achievement.description,
|
||||
icon: icon !== undefined ? (icon || null) : achievement.icon,
|
||||
trigger: trigger !== undefined ? (trigger || null) : achievement.trigger,
|
||||
is_active: is_active !== undefined ? !!is_active : achievement.is_active,
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'update_achievement', { entityType: 'achievement', details: { id: achievement.achievement_definition_id, key: achievement.key } });
|
||||
|
||||
return R.success(res, 'Achievement updated.', achievement);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPDATE ACHIEVEMENT]', err);
|
||||
return R.error(res, 'Could not update achievement.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE /admin/achievements/:id ───────────────────────────────────────────
|
||||
|
||||
exports.deleteAchievement = async (req, res) => {
|
||||
try {
|
||||
const achievement = await mdl_AchievementDefinitions.findByPk(req.params.id);
|
||||
if (!achievement) return R.error(res, 'Achievement not found.', 404);
|
||||
if (achievement.is_system) return R.error(res, 'Built-in system achievements cannot be deleted.', 400);
|
||||
|
||||
const assignedCourses = await CourseAchievement.count({ where: { achievement_key: achievement.key } });
|
||||
if (assignedCourses > 0)
|
||||
return R.error(res, `Cannot delete — ${assignedCourses} course(s) still reference this achievement. Unassign it first.`, 409);
|
||||
|
||||
await achievement.destroy();
|
||||
logActivity(req.user?.user_id, 'delete_achievement', { entityType: 'achievement', details: { key: achievement.key } });
|
||||
return R.success(res, 'Achievement deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DELETE ACHIEVEMENT]', err);
|
||||
return R.error(res, 'Could not delete achievement.', 500);
|
||||
}
|
||||
};
|
||||
@@ -4,9 +4,11 @@ const sequelize = require("../../config/db.config");
|
||||
const Advertisement = require("../../models/advertisements/advertisements.mdl");
|
||||
const mdl_Assets = require("../../models/assets/assets.mdl");
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mediaToken = require("../../services/mediaToken.service");
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/advertisements/advertisements.attributes");
|
||||
const { PLACEMENT_MAP, PLACEMENT_KEYS } = require("../../models/advertisements/advertisements.placements");
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
@@ -16,9 +18,32 @@ const { Op } = require('sequelize');
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"];
|
||||
const ALLOWED_STATUSES = ["draft", "active", "scheduled", "expired", "archived"];
|
||||
|
||||
// Fields needed off the associated Asset to render a preview AND (for S3 assets)
|
||||
// mint a stream token — storage_key is stripped again in attachImageStreamToken
|
||||
// before the row is ever sent out.
|
||||
const AD_IMAGE_ATTRIBUTES = ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"];
|
||||
|
||||
// ─── Media proxying ─────────────────────────────────────────────────────────
|
||||
// Mirrors controllers/admin/assets.controller.js's redactS3Url/attachStreamTokens.
|
||||
// Private (S3-backed) advertisement images must never expose a raw file_url to
|
||||
// the browser — mint a short-lived stream token instead so the frontend resolves
|
||||
// it through GET /api/client/media/stream/:token. Public/chibisafe images keep
|
||||
// their direct file_url (no proxy needed).
|
||||
async function attachImageStreamToken(image, req) {
|
||||
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
|
||||
return image;
|
||||
}
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
|
||||
image.stream_token = token;
|
||||
image.file_url = null;
|
||||
image.thumbnail_url = null;
|
||||
delete image.storage_key;
|
||||
return image;
|
||||
}
|
||||
|
||||
// ─── Status derivation ─────────────────────────────────────────────────────
|
||||
// status is never trusted as manually-set truth — it's derived from is_active
|
||||
// + start_date/end_date every time an advertisement is read or written.
|
||||
@@ -53,13 +78,17 @@ function normalizeCtas(ctas) {
|
||||
}
|
||||
|
||||
async function applyAdvertisementFields(advertisement, body) {
|
||||
if (body.type !== undefined) {
|
||||
if (!ALLOWED_TYPES.includes(body.type)) {
|
||||
const err = new Error(`Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`);
|
||||
// placement is the only settable "where" — type/format is always derived
|
||||
// from the placement's registry entry, never accepted directly from the body.
|
||||
if (body.placement !== undefined) {
|
||||
const entry = PLACEMENT_MAP[body.placement];
|
||||
if (!entry) {
|
||||
const err = new Error(`Invalid placement. Must be one of: ${PLACEMENT_KEYS.join(", ")}`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
advertisement.type = body.type;
|
||||
advertisement.placement = body.placement;
|
||||
advertisement.type = entry.format;
|
||||
}
|
||||
|
||||
// status is intentionally NOT settable here — it's derived via deriveStatus()
|
||||
@@ -93,8 +122,8 @@ async function applyAdvertisementFields(advertisement, body) {
|
||||
if (body.is_active !== undefined) advertisement.is_active = body.is_active === true || body.is_active === "true";
|
||||
|
||||
if (body.size !== undefined) {
|
||||
if (body.size !== null && !["sm", "md", "lg"].includes(body.size)) {
|
||||
const err = new Error(`Invalid size. Must be one of: sm, md, lg`);
|
||||
if (body.size !== null && !["sm", "md", "lg", "xl"].includes(body.size)) {
|
||||
const err = new Error(`Invalid size. Must be one of: sm, md, lg, xl`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
@@ -120,7 +149,7 @@ exports.getAdvertisements = async (req, res) => {
|
||||
include: [{
|
||||
model: mdl_Assets,
|
||||
as: "image",
|
||||
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"],
|
||||
attributes: AD_IMAGE_ATTRIBUTES,
|
||||
required: false,
|
||||
}],
|
||||
},
|
||||
@@ -129,7 +158,10 @@ exports.getAdvertisements = async (req, res) => {
|
||||
// Resync status on the way out — never trust what's stored, since
|
||||
// start_date/end_date may have lapsed since the row was last saved.
|
||||
if (Array.isArray(result?.data)) {
|
||||
result.data = result.data.map((row) => ({ ...row, status: deriveStatus(row) }));
|
||||
result.data = await Promise.all(result.data.map(async (row) => {
|
||||
if (row.image) await attachImageStreamToken(row.image, req);
|
||||
return { ...row, status: deriveStatus(row) };
|
||||
}));
|
||||
}
|
||||
|
||||
return R.success(res, "Advertisements retrieved.", result);
|
||||
@@ -149,7 +181,7 @@ exports.getAdvertisement = async (req, res) => {
|
||||
const advertisement = await Advertisement.findOne({
|
||||
where: { advertisement_id: advertisementId, ...notDeleted },
|
||||
include: [
|
||||
{ model: mdl_Assets, as: "image", attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"], required: false },
|
||||
{ model: mdl_Assets, as: "image", attributes: AD_IMAGE_ATTRIBUTES, required: false },
|
||||
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
|
||||
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
|
||||
],
|
||||
@@ -160,6 +192,8 @@ exports.getAdvertisement = async (req, res) => {
|
||||
const json = advertisement.toJSON();
|
||||
json.status = deriveStatus(json);
|
||||
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
|
||||
if (json.creator) {
|
||||
json.creator = {
|
||||
user_id: json.creator.user_id,
|
||||
@@ -184,20 +218,21 @@ exports.getAdvertisement = async (req, res) => {
|
||||
|
||||
exports.createAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { type, createdBy } = req.body;
|
||||
const { placement, createdBy } = req.body;
|
||||
|
||||
if (!type) return R.error(res, "type is required.", 400);
|
||||
if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 400);
|
||||
if (!placement) return R.error(res, "placement is required.", 400);
|
||||
const entry = PLACEMENT_MAP[placement];
|
||||
if (!entry) return R.error(res, `Invalid placement. Must be one of: ${PLACEMENT_KEYS.join(", ")}`, 400);
|
||||
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const advertisement = await Advertisement.build({ type, createdBy });
|
||||
const advertisement = await Advertisement.build({ placement, type: entry.format, createdBy });
|
||||
await applyAdvertisementFields(advertisement, req.body);
|
||||
await advertisement.save({ transaction: t });
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'create_advertisement', { entityType: 'advertisement', entityId: advertisement.advertisement_id, details: { type: advertisement.type } });
|
||||
logActivity(req.user?.user_id, 'create_advertisement', { entityType: 'advertisement', entityId: advertisement.advertisement_id, details: { placement: advertisement.placement, type: advertisement.type } });
|
||||
return R.success(res, "Advertisement created.", { data: advertisement }, 201);
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* connection gone */ }
|
||||
|
||||
@@ -6,6 +6,7 @@ const sequelize = require("../../config/db.config");
|
||||
const Asset = require("../../models/assets/assets.mdl");
|
||||
const chibi = require("../../services/chibisafe.service");
|
||||
const s3 = require("../../services/s3.service");
|
||||
const mediaToken = require("../../services/mediaToken.service");
|
||||
const { extractVideoMeta } = require("../../services/ffprobe.service");
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
@@ -20,6 +21,29 @@ const { Op } = require('sequelize');
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
// List queries keep storage_key selected (unlike adminExclude) so
|
||||
// attachStreamTokens can sign a stream token server-side without a second
|
||||
// query — it's deleted from every row before the response is sent.
|
||||
const LIST_QUERY_EXCLUDE = adminExclude.filter((f) => f !== "storage_key");
|
||||
|
||||
// ─── In-memory list cache (no Redis yet) ───────────────────────────────────────
|
||||
// Short TTL just to absorb bursts of identical GET /admin/assets calls — e.g.
|
||||
// AssetPickerSheet being opened/closed repeatedly with the same filters — so
|
||||
// Postgres isn't re-queried on every toggle. Cleared on any mutation below.
|
||||
// Single-process only; fine for one instance, won't stay consistent across
|
||||
// multiple app instances without a shared store like Redis.
|
||||
const LIST_CACHE_TTL_MS = 20_000;
|
||||
const listCache = new Map(); // queryKey -> { result, expiresAt }
|
||||
|
||||
function listCacheKey(req) {
|
||||
return JSON.stringify({
|
||||
page: req.query.page, limit: req.query.limit,
|
||||
filters: req.query.filters, sort: req.query.sort,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateListCache() { listCache.clear(); }
|
||||
|
||||
function resolveFileType(mimeType = "") {
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
@@ -166,20 +190,63 @@ function redactS3Url(asset) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
// ─── attachStreamTokens ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Embeds a stream_token (+ presigned thumbnail_url) directly into each S3 row
|
||||
// so pickers/tables reading the list can render thumbnails immediately instead
|
||||
// of firing a second POST /admin/media/tokens round-trip and waiting on it.
|
||||
// storage_key is kept out of the DB attribute exclude list (unlike the rest of
|
||||
// adminExclude) purely so it's available here to sign the token — it's still
|
||||
// stripped from every row before the response goes out.
|
||||
//
|
||||
// Operates on shallow copies: `result.data` is shared with listCache, and
|
||||
// mutating those rows in place would delete storage_key from the cached
|
||||
// objects, breaking token issuance for the next request that hits the cache.
|
||||
//
|
||||
async function attachStreamTokens(rows, req) {
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const userId = req.user?.user_id;
|
||||
|
||||
return Promise.all(rows.map(async (original) => {
|
||||
const row = { ...original };
|
||||
const eligible = row.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(row.file_type);
|
||||
|
||||
if (eligible) {
|
||||
const { token, thumbnail_url } = await mediaToken.issueForAsset(row, userId, ip);
|
||||
row.stream_token = token;
|
||||
if (thumbnail_url) row.thumbnail_url = thumbnail_url;
|
||||
}
|
||||
|
||||
delete row.storage_key;
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getAssets = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Asset, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'Asset' },
|
||||
findOptions: { where: { deletedAt: null } },
|
||||
});
|
||||
result.data = result.data.map(redactS3Url);
|
||||
return R.success(res, "Assets retrieved.", result);
|
||||
const key = listCacheKey(req);
|
||||
const cached = listCache.get(key);
|
||||
let result;
|
||||
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
result = cached.result;
|
||||
} else {
|
||||
result = await paginate(Asset, req, {
|
||||
excludeAttributes: LIST_QUERY_EXCLUDE,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'Asset' },
|
||||
findOptions: { where: { deletedAt: null } },
|
||||
});
|
||||
result.data = result.data.map(redactS3Url);
|
||||
listCache.set(key, { result, expiresAt: Date.now() + LIST_CACHE_TTL_MS });
|
||||
}
|
||||
|
||||
const data = await attachStreamTokens(result.data, req);
|
||||
return R.success(res, "Assets retrieved.", { ...result, data });
|
||||
} catch (err) {
|
||||
console.error("[ASSET][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve assets.", 500);
|
||||
@@ -195,7 +262,9 @@ exports.getAsset = async (req, res) => {
|
||||
|
||||
const asset = await Asset.findOne({
|
||||
where: { asset_id: assetId, ...notDeleted },
|
||||
attributes: { exclude: ["storage_key", "storage_bucket"] },
|
||||
// storage_key stays selected here (unlike the list query) so it's
|
||||
// available below to sign a stream token — stripped before the response.
|
||||
attributes: { exclude: ["storage_bucket"] },
|
||||
include: [
|
||||
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
|
||||
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
|
||||
@@ -219,6 +288,14 @@ exports.getAsset = async (req, res) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (json.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(json.file_type)) {
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token, thumbnail_url } = await mediaToken.issueForAsset(json, req.user?.user_id, ip);
|
||||
json.stream_token = token;
|
||||
if (thumbnail_url) json.thumbnail_url = thumbnail_url;
|
||||
}
|
||||
delete json.storage_key;
|
||||
|
||||
redactS3Url(json);
|
||||
return R.success(res, "Asset retrieved.", { data: json });
|
||||
} catch (err) {
|
||||
@@ -404,6 +481,7 @@ exports.uploadAsset = async (req, res) => {
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
|
||||
return R.success(res, "Asset uploaded.", { data: asset }, 201);
|
||||
|
||||
@@ -471,6 +549,7 @@ exports.updateAsset = async (req, res) => {
|
||||
|
||||
if (uploaded) await deleteOldFile(storageProvider, oldStorageKey, uploaded.storage_key);
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "Asset updated.", { data: asset });
|
||||
|
||||
@@ -494,6 +573,7 @@ exports.archiveAsset = async (req, res) => {
|
||||
|
||||
await asset.update({ deletedBy: req.body.deletedBy ?? null });
|
||||
await asset.destroy();
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "Asset archived.");
|
||||
} catch (err) {
|
||||
@@ -517,6 +597,7 @@ exports.archiveAssets = async (req, res) => {
|
||||
await Asset.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } });
|
||||
await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
|
||||
return R.success(res, `${activeIds.length} asset(s) archived.`, {
|
||||
archived_ids: activeIds,
|
||||
@@ -540,6 +621,7 @@ exports.restoreAsset = async (req, res) => {
|
||||
|
||||
await asset.restore();
|
||||
await asset.update({ deletedBy: null });
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "Asset restored.", { data: asset });
|
||||
} catch (err) {
|
||||
@@ -566,6 +648,7 @@ exports.restoreAssets = async (req, res) => {
|
||||
await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } });
|
||||
await Asset.update({ deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false });
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} asset(s) restored.`, {
|
||||
restored_ids: archivedIds,
|
||||
|
||||
@@ -24,10 +24,13 @@ const {
|
||||
UnitQuiz, QuizQuestion, QuizOption,
|
||||
QuizAttempt, AssessmentSession,
|
||||
CourseInstructor, CourseAchievement,
|
||||
UnitReadingProgress, LessonReadingProgress,
|
||||
} = require("../../models/courses/courses.associations");
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
|
||||
const { mdl_PlanCourses, mdl_TierPlans } = require("../../models/tiers/tier.associations");
|
||||
|
||||
const {
|
||||
excludeAttributes: courseExclude,
|
||||
computedAttributes: courseComputed,
|
||||
@@ -46,7 +49,15 @@ exports.getCourses = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Course, req, {
|
||||
excludeAttributes: courseExclude,
|
||||
computedAttributes: courseComputed,
|
||||
computedAttributes: [
|
||||
...courseComputed,
|
||||
{
|
||||
key: "assessment_id",
|
||||
label: "Assessment ID",
|
||||
type: "text",
|
||||
literal: `(SELECT assessment_id FROM course_assessments WHERE course_id = "Course"."course_id" AND "deletedAt" IS NULL LIMIT 1)`,
|
||||
},
|
||||
],
|
||||
auditOptions: { mdl_Users, parentAlias: "Course" },
|
||||
context: "list",
|
||||
findOptions: { where: { ...notDeleted } },
|
||||
@@ -126,7 +137,7 @@ exports.createCourse = async (req, res) => {
|
||||
|
||||
if (achievement_keys.length) {
|
||||
await CourseAchievement.bulkCreate(
|
||||
achievement_keys.slice(0, 3).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
|
||||
achievement_keys.slice(0, 1).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
|
||||
{ transaction: t },
|
||||
);
|
||||
}
|
||||
@@ -278,6 +289,30 @@ exports.bulkRestoreCourses = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.getCourseArchiveImpact = async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
|
||||
const [activeCount, totalCount] = await Promise.all([
|
||||
UnitReadingProgress.count({
|
||||
where: { course_id: courseId, status: "in_progress" },
|
||||
distinct: true,
|
||||
col: "user_id",
|
||||
}),
|
||||
UnitReadingProgress.count({
|
||||
where: { course_id: courseId },
|
||||
distinct: true,
|
||||
col: "user_id",
|
||||
}),
|
||||
]);
|
||||
|
||||
return R.success(res, "Impact retrieved.", { activeCount, totalCount });
|
||||
} catch (err) {
|
||||
console.error("[COURSE][ARCHIVE IMPACT]", err);
|
||||
return R.error(res, "Could not retrieve impact.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// COURSE PREREQUISITES
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -364,6 +399,22 @@ exports.getUnits = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnitArchiveImpact = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const [completionCount, progressCount] = await Promise.all([
|
||||
UnitReadingProgress.count({ where: { unit_id: unitId, status: "completed" } }),
|
||||
LessonReadingProgress.count({ where: { unit_id: unitId }, distinct: true, col: "user_id" }),
|
||||
]);
|
||||
|
||||
return R.success(res, "Impact retrieved.", { completionCount, progressCount });
|
||||
} catch (err) {
|
||||
console.error("[UNIT][ARCHIVE IMPACT]", err);
|
||||
return R.error(res, "Could not retrieve impact.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnit = async (req, res) => {
|
||||
try {
|
||||
const { courseId, unitId } = req.params;
|
||||
@@ -1234,6 +1285,87 @@ exports.bulkRestoreQuestions = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkSyncQuestions = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const parent = await resolveQuestionParent(req.params);
|
||||
if (!parent?.parentRecord) return R.error(res, "Parent not found.", 404);
|
||||
|
||||
const { questions = [], updatedBy } = req.body;
|
||||
|
||||
const existing = await QuizQuestion.findAll({
|
||||
where: { [parent.parentField]: parent.parentId, ...notDeleted },
|
||||
});
|
||||
const existingIds = existing.map((q) => q.question_id);
|
||||
const incomingIds = questions.filter((q) => q.question_id).map((q) => q.question_id);
|
||||
const toArchive = existingIds.filter((id) => !incomingIds.includes(id));
|
||||
|
||||
if (toArchive.length) {
|
||||
await QuizQuestion.update(
|
||||
{ deletedAt: new Date(), deletedBy: updatedBy ?? null },
|
||||
{ where: { question_id: toArchive }, transaction: t }
|
||||
);
|
||||
}
|
||||
|
||||
const result = [];
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const { question_id, type, question, explanation, points, options = [] } = questions[i];
|
||||
|
||||
if (question_id && existingIds.includes(question_id)) {
|
||||
const q = existing.find((e) => e.question_id === question_id);
|
||||
q.type = type ?? q.type;
|
||||
q.question = question ?? q.question;
|
||||
q.explanation = explanation ?? null;
|
||||
q.order_index = i;
|
||||
q.points = points ?? q.points;
|
||||
q.updatedBy = updatedBy ?? null;
|
||||
await q.save({ transaction: t });
|
||||
|
||||
await QuizOption.destroy({ where: { question_id }, transaction: t });
|
||||
if (options.length) {
|
||||
await QuizOption.bulkCreate(
|
||||
options.map((o, oi) => ({ question_id, text: o.text, is_correct: o.is_correct ?? false, order_index: oi })),
|
||||
{ transaction: t }
|
||||
);
|
||||
}
|
||||
result.push(question_id);
|
||||
} else {
|
||||
const q = await QuizQuestion.create({
|
||||
[parent.parentField]: parent.parentId,
|
||||
type, question,
|
||||
explanation: explanation ?? null,
|
||||
order_index: i,
|
||||
points: points ?? 1,
|
||||
createdBy: updatedBy ?? null,
|
||||
}, { transaction: t });
|
||||
|
||||
if (options.length) {
|
||||
await QuizOption.bulkCreate(
|
||||
options.map((o, oi) => ({ question_id: q.question_id, text: o.text, is_correct: o.is_correct ?? false, order_index: oi })),
|
||||
{ transaction: t }
|
||||
);
|
||||
}
|
||||
result.push(q.question_id);
|
||||
}
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const synced = await QuizQuestion.findAll({
|
||||
where: { question_id: result },
|
||||
order: [["order_index", "ASC"]],
|
||||
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_sync_questions', { entityType: 'question', details: { parentField: parent.parentField, parentId: parent.parentId, count: synced.length } });
|
||||
return R.success(res, "Questions synced.", { data: synced });
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[QUESTION][BULK SYNC]", err);
|
||||
return R.error(res, "Could not sync questions.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// COURSE ASSESSMENT
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1341,11 +1473,12 @@ exports.updateAssessment = async (req, res) => {
|
||||
|
||||
const course = await Course.findOne({
|
||||
where: { course_id: courseId },
|
||||
attributes: ['title'],
|
||||
attributes: ['title', 'uuid'],
|
||||
});
|
||||
const notify = NOTIFICATION_REGISTRY.assessment_updated.build({
|
||||
assessmentTitle: assessment.title,
|
||||
courseTitle: course?.title ?? null,
|
||||
courseUuid: course?.uuid ?? null,
|
||||
});
|
||||
const now = new Date();
|
||||
await UserNotification.bulkCreate(
|
||||
@@ -1433,7 +1566,7 @@ exports.getCoursesFlat = async (req, res) => {
|
||||
try {
|
||||
const data = await Course.findAll({
|
||||
where: notDeleted,
|
||||
attributes: ["uuid", "title"],
|
||||
attributes: ["uuid", "title", "subscription", "duration_seconds"],
|
||||
order: [["title", "ASC"]],
|
||||
});
|
||||
return R.success(res, "Courses retrieved.", data);
|
||||
@@ -1448,11 +1581,32 @@ exports.getCoursesBySubscription = async (req, res) => {
|
||||
const { slug } = req.query;
|
||||
if (!slug) return R.error(res, 'slug query param is required.', 400);
|
||||
|
||||
const data = await Course.findAll({
|
||||
const rows = await Course.findAll({
|
||||
where: { ...notDeleted, subscription: slug },
|
||||
attributes: ['course_id', 'title', 'description', 'subscription'],
|
||||
include: [{
|
||||
model: mdl_PlanCourses,
|
||||
as: 'planCourse',
|
||||
required: false,
|
||||
attributes: ['plan_id'],
|
||||
include: [{
|
||||
model: mdl_TierPlans,
|
||||
as: 'plan',
|
||||
attributes: ['plan_id', 'label'],
|
||||
}],
|
||||
}],
|
||||
order: [['title', 'ASC']],
|
||||
});
|
||||
|
||||
// Flatten so the frontend can just check `assigned_plan` — a course belongs
|
||||
// to at most one plan (UNIQUE constraint on plan_courses.course_id).
|
||||
const data = rows.map((c) => {
|
||||
const plain = c.toJSON();
|
||||
const assigned_plan = plain.planCourse?.plan ?? null;
|
||||
delete plain.planCourse;
|
||||
return { ...plain, assigned_plan };
|
||||
});
|
||||
|
||||
return R.success(res, 'Courses retrieved.', data);
|
||||
} catch (err) {
|
||||
console.error('[COURSE][BY SUBSCRIPTION]', err);
|
||||
@@ -1464,11 +1618,12 @@ exports.getUnitsFlat = async (req, res) => {
|
||||
try {
|
||||
const rows = await Unit.findAll({
|
||||
where: notDeleted,
|
||||
attributes: ["uuid", "title", "order_index"],
|
||||
attributes: ["uuid", "title", "order_index", "duration_seconds"],
|
||||
include: [{
|
||||
model: Course,
|
||||
as: "course",
|
||||
attributes: ["title"],
|
||||
attributes: ["title", "subscription"],
|
||||
paranoid: false,
|
||||
}],
|
||||
order: [
|
||||
[{ model: Course, as: "course" }, "title", "ASC"],
|
||||
@@ -1476,10 +1631,12 @@ exports.getUnitsFlat = async (req, res) => {
|
||||
],
|
||||
});
|
||||
const data = rows.map((u) => ({
|
||||
uuid: u.uuid,
|
||||
title: u.title,
|
||||
order_index: u.order_index ?? 0,
|
||||
course_title: u.course?.title ?? "",
|
||||
uuid: u.uuid,
|
||||
title: u.title,
|
||||
order_index: u.order_index ?? 0,
|
||||
duration_seconds: u.duration_seconds ?? 0,
|
||||
course_title: u.course?.title ?? "",
|
||||
subscription: u.course?.subscription ?? "free",
|
||||
}));
|
||||
return R.success(res, "Units retrieved.", data);
|
||||
} catch (err) {
|
||||
@@ -1492,15 +1649,17 @@ exports.getLessonsFlat = async (req, res) => {
|
||||
try {
|
||||
const rows = await Lesson.findAll({
|
||||
where: notDeleted,
|
||||
attributes: ["uuid", "title", "order_index"],
|
||||
attributes: ["uuid", "title", "order_index", "duration_seconds"],
|
||||
include: [{
|
||||
model: Unit,
|
||||
as: "unit",
|
||||
attributes: ["title", "order_index"],
|
||||
paranoid: false,
|
||||
include: [{
|
||||
model: Course,
|
||||
as: "course",
|
||||
attributes: ["title"],
|
||||
attributes: ["title", "subscription"],
|
||||
paranoid: false,
|
||||
}],
|
||||
}],
|
||||
order: [
|
||||
@@ -1510,12 +1669,14 @@ exports.getLessonsFlat = async (req, res) => {
|
||||
],
|
||||
});
|
||||
const data = rows.map((l) => ({
|
||||
uuid: l.uuid,
|
||||
title: l.title,
|
||||
order_index: l.order_index ?? 0,
|
||||
unit_title: l.unit?.title ?? "",
|
||||
unit_order: l.unit?.order_index ?? 0,
|
||||
course_title: l.unit?.course?.title ?? "",
|
||||
uuid: l.uuid,
|
||||
title: l.title,
|
||||
order_index: l.order_index ?? 0,
|
||||
duration_seconds: l.duration_seconds ?? 0,
|
||||
unit_title: l.unit?.title ?? "",
|
||||
unit_order: l.unit?.order_index ?? 0,
|
||||
course_title: l.unit?.course?.title ?? "",
|
||||
subscription: l.unit?.course?.subscription ?? "free",
|
||||
}));
|
||||
return R.success(res, "Lessons retrieved.", data);
|
||||
} catch (err) {
|
||||
@@ -1801,8 +1962,8 @@ exports.syncCourseAchievements = async (req, res) => {
|
||||
const { courseId } = req.params;
|
||||
const { achievement_keys = [] } = req.body;
|
||||
|
||||
if (achievement_keys.length > 3)
|
||||
return R.error(res, "Maximum 3 achievements allowed per course.", 400);
|
||||
if (achievement_keys.length > 1)
|
||||
return R.error(res, "Maximum 1 achievement allowed per course.", 400);
|
||||
|
||||
await CourseAchievement.destroy({ where: { course_id: courseId }, transaction: t });
|
||||
|
||||
|
||||
@@ -20,6 +20,27 @@
|
||||
|
||||
---
|
||||
|
||||
## Placement Registry
|
||||
|
||||
Every advertisement belongs to a `placement` — a page + position slug drawn from a fixed
|
||||
registry (`models/advertisements/advertisements.placements.js`). The placement determines
|
||||
the advertisement's `type` (visual format) automatically; `type` is **never** accepted from
|
||||
the client and is denormalized from the placement on every write.
|
||||
|
||||
| Placement key | Page | Position | Format |
|
||||
|---|---|---|---|
|
||||
| `dashboard.hero` | Dashboard | Hero (top of page) | `hero` |
|
||||
| `dashboard.popup` | Dashboard | Popup (on load) | `popup` |
|
||||
| `course_list.banner` | Courses | Banner (above course grid) | `banner` |
|
||||
| `course_details.banner` | Course Details | Banner (below hero) | `banner` |
|
||||
| `course_details.sidebar` | Course Details | Sidebar (beside course content) | `sidebar` |
|
||||
| `plans.banner` | Plans | Banner (above plan cards) | `banner` |
|
||||
|
||||
Adding a new placement is a one-line addition to that registry file plus wiring the
|
||||
corresponding client page to fetch/render it — nothing else needs to change.
|
||||
|
||||
---
|
||||
|
||||
## Status Derivation
|
||||
|
||||
Status is **never** trusted as stored — it is recomputed on every read and write:
|
||||
@@ -93,6 +114,7 @@ Returns one advertisement with its `image` asset and audit user info.
|
||||
"data": {
|
||||
"advertisement_id": 1,
|
||||
"uuid": "...",
|
||||
"placement": "dashboard.hero",
|
||||
"type": "hero",
|
||||
"status": "active",
|
||||
"badge_label": "New",
|
||||
@@ -131,7 +153,7 @@ Returns one advertisement with its `image` asset and audit user info.
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `type` | string | **Yes** | `hero`, `banner`, `popup`, `sidebar` |
|
||||
| `placement` | string | **Yes** | A placement registry key, e.g. `dashboard.hero` — see [Placement Registry](#placement-registry). Determines `type` automatically. |
|
||||
| `createdBy` | number | **Yes** | User ID of creator |
|
||||
| `badge_label` | string | No | Small label shown on the ad |
|
||||
| `headline` | string | No | Main heading |
|
||||
@@ -157,8 +179,8 @@ Returns one advertisement with its `image` asset and audit user info.
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `type is required.` |
|
||||
| `400` | `Invalid type. Must be one of: hero, banner, popup, sidebar` |
|
||||
| `400` | `placement is required.` |
|
||||
| `400` | `Invalid placement. Must be one of: dashboard.hero, dashboard.popup, ...` |
|
||||
| `400` | `createdBy is required.` |
|
||||
| `400` | `Invalid size. Must be one of: sm, md, lg` |
|
||||
|
||||
@@ -171,7 +193,7 @@ Returns one advertisement with its `image` asset and audit user info.
|
||||
Partial update. Only fields present in the body are changed. Status is recomputed after all fields are applied.
|
||||
|
||||
### Request Body
|
||||
Same optional fields as Create. Does not accept `type` once set. Accepts `updatedBy`.
|
||||
Same optional fields as Create. `type` is never accepted — it's always derived from `placement`. Accepts `updatedBy`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
@@ -279,6 +301,10 @@ Returns distinct values for filterable advertisement fields. Used by DataTable f
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Field values retrieved.",
|
||||
"data": { "type": ["hero", "banner"], "status": ["active", "draft"] }
|
||||
"data": {
|
||||
"type": ["hero", "banner", "popup", "sidebar"],
|
||||
"placement": ["dashboard.hero", "dashboard.popup", "course_list.banner"],
|
||||
"status": ["active", "draft"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_EmailBroadcast = require('../../models/email_templates/email_broadcast.mdl');
|
||||
const mdl_EmailBroadcastRecipient = require('../../models/email_templates/email_broadcast_recipient.mdl');
|
||||
const mdl_EmailTemplate = require('../../models/email_templates/email_templates.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const {
|
||||
ALLOWED_TARGET_TYPES,
|
||||
SCOPED_TARGET_TYPES,
|
||||
validateTargetId,
|
||||
resolveTargetUserIds,
|
||||
resolveAllUserIds,
|
||||
} = require('../../utils/audienceResolver.util');
|
||||
|
||||
// Only these categories are ever appropriate to blast to real recipients —
|
||||
// system/transactional templates (OTP, welcome, banned, etc.) are triggered
|
||||
// per-user by app events and are deliberately NOT broadcastable here.
|
||||
const BROADCASTABLE_CATEGORIES = ['announcement', 'advertisement'];
|
||||
|
||||
// 'admin' means "email every admin/staff user" for this feature — distinct
|
||||
// from notification broadcasts' 'admin' (which posts to the shared bell feed
|
||||
// instead of emailing anyone). Kept local to this controller for that reason.
|
||||
async function resolveAdminStaffUserIds() {
|
||||
const users = await mdl_Users.findAll({
|
||||
attributes: ['user_id'],
|
||||
where: { acc_type: { [Op.in]: ['admin', 'staff'] }, deletedAt: null },
|
||||
raw: true,
|
||||
});
|
||||
return users.map((u) => String(u.user_id));
|
||||
}
|
||||
|
||||
async function resolveAudienceUserIds(target_type, target_id) {
|
||||
if (target_type === 'admin') return resolveAdminStaffUserIds();
|
||||
if (target_type === 'user') return resolveAllUserIds();
|
||||
if (target_type === 'both') {
|
||||
const [admins, users] = await Promise.all([resolveAdminStaffUserIds(), resolveAllUserIds()]);
|
||||
return [...new Set([...admins, ...users])];
|
||||
}
|
||||
return resolveTargetUserIds(target_type, target_id);
|
||||
}
|
||||
|
||||
// ─── GET /admin/email-broadcasts ──────────────────────────────────────────────
|
||||
|
||||
exports.getEmailBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const broadcasts = await mdl_EmailBroadcast.findAll({
|
||||
order: [['createdAt', 'DESC']],
|
||||
include: [{ model: mdl_EmailTemplate, as: 'template', attributes: ['email_template_id', 'type', 'label', 'category'] }],
|
||||
});
|
||||
return R.success(res, 'Email broadcasts retrieved.', broadcasts);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET EMAIL BROADCASTS]', err);
|
||||
return R.error(res, 'Could not retrieve email broadcasts.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /admin/email-broadcasts/:id ──────────────────────────────────────────
|
||||
|
||||
exports.getEmailBroadcast = async (req, res) => {
|
||||
try {
|
||||
const broadcast = await mdl_EmailBroadcast.findByPk(req.params.id, {
|
||||
include: [{ model: mdl_EmailTemplate, as: 'template', attributes: ['email_template_id', 'type', 'label', 'category'] }],
|
||||
});
|
||||
if (!broadcast) return R.error(res, 'Email broadcast not found.', 404);
|
||||
|
||||
const failedRecipients = await mdl_EmailBroadcastRecipient.findAll({
|
||||
where: { email_broadcast_id: broadcast.email_broadcast_id, status: 'failed' },
|
||||
attributes: ['email', 'error'],
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
return R.success(res, 'Email broadcast retrieved.', { ...broadcast.toJSON(), failed_recipients: failedRecipients });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET EMAIL BROADCAST]', err);
|
||||
return R.error(res, 'Could not retrieve email broadcast.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/email-broadcasts ─────────────────────────────────────────────
|
||||
// Enqueues recipients and returns immediately — actual sending happens later,
|
||||
// paced, in cron/jobs/dispatch_email_broadcasts.cron.js. Never loops over
|
||||
// recipients or calls sendEmail() here.
|
||||
|
||||
exports.createEmailBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { email_template_id, target_type, target_id, createdBy } = req.body;
|
||||
|
||||
if (!email_template_id) return R.error(res, 'email_template_id is required.', 400);
|
||||
if (!target_type) return R.error(res, 'target_type is required.', 400);
|
||||
if (!ALLOWED_TARGET_TYPES.includes(target_type)) return R.error(res, `Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(', ')}`, 400);
|
||||
if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, 'target_id is required for this target_type.', 400);
|
||||
|
||||
const template = await mdl_EmailTemplate.findByPk(email_template_id);
|
||||
if (!template) return R.error(res, 'Email template not found.', 404);
|
||||
if (!BROADCASTABLE_CATEGORIES.includes(template.category)) {
|
||||
return R.error(res, `Only ${BROADCASTABLE_CATEGORIES.join('/')} templates can be sent to recipients.`, 400);
|
||||
}
|
||||
if (template.status !== 'sent' || !template.subject || !template.html_body) {
|
||||
return R.error(res, 'This template has no published (sent) version yet — publish it before sending to recipients.', 400);
|
||||
}
|
||||
|
||||
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
|
||||
|
||||
const userIds = await resolveAudienceUserIds(target_type, target_id);
|
||||
if (!userIds.length) return R.error(res, 'No recipients match this target.', 400);
|
||||
|
||||
const users = await mdl_Users.findAll({
|
||||
attributes: ['user_id', 'email', 'personal_info'],
|
||||
where: { user_id: { [Op.in]: userIds }, email: { [Op.ne]: null }, deletedAt: null },
|
||||
raw: true,
|
||||
});
|
||||
if (!users.length) return R.error(res, 'None of the matched recipients have a usable email address.', 400);
|
||||
|
||||
const broadcast = await mdl_EmailBroadcast.create({
|
||||
email_template_id,
|
||||
target_type,
|
||||
target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null,
|
||||
status: 'queued',
|
||||
total_recipients: users.length,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
await mdl_EmailBroadcastRecipient.bulkCreate(
|
||||
users.map((u) => ({
|
||||
email_broadcast_id: broadcast.email_broadcast_id,
|
||||
user_id: u.user_id,
|
||||
email: u.email,
|
||||
name: u.personal_info?.name?.full_name ?? null,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
{ validate: false }
|
||||
);
|
||||
|
||||
logActivity(req.user?.user_id, 'create_email_broadcast', {
|
||||
entityType: 'email_broadcast',
|
||||
entityId: broadcast.email_broadcast_id,
|
||||
details: { email_template_id, target_type, target_id, recipient_count: users.length },
|
||||
});
|
||||
|
||||
return R.success(res, `Broadcast queued for ${users.length} recipient(s).`, broadcast, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE EMAIL BROADCAST]', err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, 'Internal server error.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PATCH /admin/email-broadcasts/:id/cancel ─────────────────────────────────
|
||||
// Soft stop — the cron simply won't pick up any more pending recipients for a
|
||||
// canceled broadcast. Whatever's already sent stays sent.
|
||||
|
||||
exports.cancelEmailBroadcast = async (req, res) => {
|
||||
try {
|
||||
const broadcast = await mdl_EmailBroadcast.findByPk(req.params.id);
|
||||
if (!broadcast) return R.error(res, 'Email broadcast not found.', 404);
|
||||
if (broadcast.status === 'completed' || broadcast.status === 'canceled') {
|
||||
return R.error(res, `Broadcast is already ${broadcast.status}.`, 400);
|
||||
}
|
||||
|
||||
await broadcast.update({ status: 'canceled' });
|
||||
logActivity(req.user?.user_id, 'cancel_email_broadcast', { entityType: 'email_broadcast', entityId: broadcast.email_broadcast_id });
|
||||
return R.success(res, 'Email broadcast canceled.', broadcast);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CANCEL EMAIL BROADCAST]', err);
|
||||
return R.error(res, 'Internal server error.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
|
||||
const mdl_EmailTemplate = require('../../models/email_templates/email_templates.mdl');
|
||||
const mdl_EmailBroadcast = require('../../models/email_templates/email_broadcast.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
const TYPE_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
||||
const VALID_CATEGORIES = ['announcement', 'advertisement', 'system', 'other'];
|
||||
|
||||
// ─── GET /admin/email-templates ───────────────────────────────────────────────
|
||||
|
||||
exports.getEmailTemplates = async (req, res) => {
|
||||
try {
|
||||
const templates = await mdl_EmailTemplate.findAll({
|
||||
order: [['category', 'ASC'], ['is_system', 'DESC'], ['type', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Email templates retrieved.', templates);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET EMAIL TEMPLATES]', err);
|
||||
return R.error(res, 'Could not retrieve email templates.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /admin/email-templates/:id ───────────────────────────────────────────
|
||||
|
||||
exports.getEmailTemplate = async (req, res) => {
|
||||
try {
|
||||
const template = await mdl_EmailTemplate.findByPk(req.params.id);
|
||||
if (!template) return R.error(res, 'Email template not found.', 404);
|
||||
return R.success(res, 'Email template retrieved.', template);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET EMAIL TEMPLATE]', err);
|
||||
return R.error(res, 'Could not retrieve email template.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/email-templates ──────────────────────────────────────────────
|
||||
|
||||
exports.createEmailTemplate = async (req, res) => {
|
||||
try {
|
||||
const { type, label, category, subject, html_body, body_markdown, publish } = req.body;
|
||||
if (!type || !label || !subject || !html_body) {
|
||||
return R.error(res, 'type, label, subject and html_body are required.', 400);
|
||||
}
|
||||
if (!TYPE_PATTERN.test(type)) {
|
||||
return R.error(res, 'type must be uppercase letters, numbers or underscores, starting with a letter (e.g. INVOICE_RECEIPT).', 400);
|
||||
}
|
||||
if (category !== undefined && !VALID_CATEGORIES.includes(category)) {
|
||||
return R.error(res, `category must be one of: ${VALID_CATEGORIES.join(', ')}.`, 400);
|
||||
}
|
||||
|
||||
const exists = await mdl_EmailTemplate.findOne({ where: { type } });
|
||||
if (exists) return R.error(res, `An email template with type "${type}" already exists.`, 409);
|
||||
|
||||
// "Send Now" writes straight to the live columns sendEmail() reads.
|
||||
// "Save as Draft" keeps the content out of the live columns entirely, so
|
||||
// there's nothing for sendEmail() to pick up until it's published.
|
||||
const isPublishing = publish === true || publish === 'true';
|
||||
|
||||
const template = await mdl_EmailTemplate.create({
|
||||
type,
|
||||
label,
|
||||
category: category || 'other',
|
||||
status: isPublishing ? 'sent' : 'draft',
|
||||
subject: isPublishing ? subject : null,
|
||||
html_body: isPublishing ? html_body : null,
|
||||
body_markdown: isPublishing ? (body_markdown ?? null) : null,
|
||||
draft_subject: isPublishing ? null : subject,
|
||||
draft_html_body: isPublishing ? null : html_body,
|
||||
draft_body_markdown: isPublishing ? null : (body_markdown ?? null),
|
||||
last_sent_at: isPublishing ? new Date() : null,
|
||||
is_system: false, // only seed data may be system-protected
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'create_email_template', { entityType: 'email_template', details: { type, label, category: template.category, status: template.status } });
|
||||
|
||||
return R.success(res, 'Email template created.', template, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE EMAIL TEMPLATE]', err);
|
||||
return R.error(res, 'Could not create email template.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT /admin/email-templates/:id ───────────────────────────────────────────
|
||||
|
||||
exports.updateEmailTemplate = async (req, res) => {
|
||||
try {
|
||||
const template = await mdl_EmailTemplate.findByPk(req.params.id);
|
||||
if (!template) return R.error(res, 'Email template not found.', 404);
|
||||
|
||||
const { type, label, category, subject, html_body, body_markdown, publish } = req.body;
|
||||
|
||||
if (template.is_system && type !== undefined && type !== template.type) {
|
||||
return R.error(res, 'The type of a system email template cannot be changed.', 400);
|
||||
}
|
||||
if (type !== undefined && !TYPE_PATTERN.test(type)) {
|
||||
return R.error(res, 'type must be uppercase letters, numbers or underscores, starting with a letter (e.g. INVOICE_RECEIPT).', 400);
|
||||
}
|
||||
if (category !== undefined && !VALID_CATEGORIES.includes(category)) {
|
||||
return R.error(res, `category must be one of: ${VALID_CATEGORIES.join(', ')}.`, 400);
|
||||
}
|
||||
|
||||
if (!template.is_system && type !== undefined && type !== template.type) {
|
||||
const exists = await mdl_EmailTemplate.findOne({ where: { type } });
|
||||
if (exists) return R.error(res, `An email template with type "${type}" already exists.`, 409);
|
||||
}
|
||||
|
||||
if (subject !== undefined && !subject.trim()) return R.error(res, 'subject cannot be empty.', 400);
|
||||
if (html_body !== undefined && !html_body.trim()) return R.error(res, 'html_body cannot be empty.', 400);
|
||||
|
||||
// "Send" publishes subject/html_body straight to the live columns that
|
||||
// sendEmail() reads and clears any pending draft. A plain save (no
|
||||
// publish flag) writes into draft_subject/draft_html_body instead, so
|
||||
// real outgoing mail keeps using the last-published content until an
|
||||
// admin comes back and explicitly sends again.
|
||||
const isPublishing = publish === true || publish === 'true';
|
||||
const nextSubject = subject ?? template.draft_subject ?? template.subject;
|
||||
const nextHtmlBody = html_body ?? template.draft_html_body ?? template.html_body;
|
||||
const nextMarkdown = body_markdown ?? template.draft_body_markdown ?? template.body_markdown;
|
||||
|
||||
await template.update({
|
||||
type: (!template.is_system && type !== undefined) ? type : template.type,
|
||||
label: label ?? template.label,
|
||||
category: category ?? template.category,
|
||||
...(isPublishing
|
||||
? {
|
||||
status: 'sent',
|
||||
subject: nextSubject,
|
||||
html_body: nextHtmlBody,
|
||||
body_markdown: nextMarkdown,
|
||||
draft_subject: null,
|
||||
draft_html_body: null,
|
||||
draft_body_markdown: null,
|
||||
last_sent_at: new Date(),
|
||||
}
|
||||
: {
|
||||
draft_subject: nextSubject,
|
||||
draft_html_body: nextHtmlBody,
|
||||
draft_body_markdown: nextMarkdown,
|
||||
}),
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'update_email_template', { entityType: 'email_template', entityId: template.email_template_id, details: { type: template.type, published: isPublishing } });
|
||||
|
||||
return R.success(res, 'Email template updated.', template);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPDATE EMAIL TEMPLATE]', err);
|
||||
return R.error(res, 'Could not update email template.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE /admin/email-templates/:id ────────────────────────────────────────
|
||||
|
||||
exports.deleteEmailTemplate = async (req, res) => {
|
||||
try {
|
||||
const template = await mdl_EmailTemplate.findByPk(req.params.id);
|
||||
if (!template) return R.error(res, 'Email template not found.', 404);
|
||||
if (template.is_system) return R.error(res, 'Built-in system email templates cannot be deleted.', 400);
|
||||
|
||||
const broadcastCount = await mdl_EmailBroadcast.count({ where: { email_template_id: template.email_template_id } });
|
||||
if (broadcastCount > 0) {
|
||||
return R.error(res, `Cannot delete — ${broadcastCount} broadcast(s) reference this template. Its send history would be lost.`, 409);
|
||||
}
|
||||
|
||||
await template.destroy();
|
||||
logActivity(req.user?.user_id, 'delete_email_template', { entityType: 'email_template', details: { type: template.type } });
|
||||
return R.success(res, 'Email template deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DELETE EMAIL TEMPLATE]', err);
|
||||
return R.error(res, 'Could not delete email template.', 500);
|
||||
}
|
||||
};
|
||||
@@ -13,37 +13,10 @@
|
||||
"use strict";
|
||||
|
||||
const { Op } = require("sequelize");
|
||||
const jwt = require("jsonwebtoken");
|
||||
|
||||
const R = require("../../utils/response.util");
|
||||
const mdl_Assets = require("../../models/assets/assets.mdl");
|
||||
const s3 = require("../../services/s3.service");
|
||||
|
||||
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
|
||||
const TOKEN_TTL_SEC = 30 * 60; // 30 min — admin preview session
|
||||
|
||||
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
|
||||
|
||||
function resolveIp(req) {
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
if (forwarded) return forwarded.split(",")[0].trim();
|
||||
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
|
||||
}
|
||||
|
||||
function signToken(asset, userId, ip) {
|
||||
return jwt.sign(
|
||||
{
|
||||
asset_id: asset.asset_id,
|
||||
user_id: userId,
|
||||
storage_key: asset.storage_key,
|
||||
file_type: asset.file_type,
|
||||
mime_type: asset.mime_type,
|
||||
ip,
|
||||
},
|
||||
MEDIA_SECRET,
|
||||
{ expiresIn: TOKEN_TTL_SEC }
|
||||
);
|
||||
}
|
||||
const R = require("../../utils/response.util");
|
||||
const mdl_Assets = require("../../models/assets/assets.mdl");
|
||||
const mediaToken = require("../../services/mediaToken.service");
|
||||
|
||||
// ─── POST /admin/media/token ──────────────────────────────────────────────────
|
||||
|
||||
@@ -59,7 +32,7 @@ exports.issueToken = async (req, res) => {
|
||||
|
||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
||||
|
||||
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
|
||||
if (!mediaToken.SUPPORTED_TYPES.includes(asset.file_type)) {
|
||||
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
|
||||
}
|
||||
|
||||
@@ -67,18 +40,8 @@ exports.issueToken = async (req, res) => {
|
||||
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
|
||||
}
|
||||
|
||||
const ip = resolveIp(req);
|
||||
const token = signToken(asset, req.user.user_id, ip);
|
||||
|
||||
// ── Presign thumbnail URL so the browser can load it directly ─────────────
|
||||
let thumbnail_url = null;
|
||||
if (asset.thumbnail_storage_key) {
|
||||
try {
|
||||
thumbnail_url = await s3.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC);
|
||||
} catch {
|
||||
// Non-fatal — thumbnail is cosmetic
|
||||
}
|
||||
}
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token, thumbnail_url } = await mediaToken.issueForAsset(asset, req.user.user_id, ip);
|
||||
|
||||
return R.success(res, "Token issued.", {
|
||||
token,
|
||||
@@ -116,26 +79,15 @@ exports.issueTokensBatch = async (req, res) => {
|
||||
attributes: ["asset_id", "file_type", "storage_key", "mime_type", "thumbnail_storage_key"],
|
||||
});
|
||||
|
||||
const ip = resolveIp(req);
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const tokens = {};
|
||||
const thumbnails = {};
|
||||
|
||||
for (const asset of assets) {
|
||||
tokens[String(asset.asset_id)] = signToken(asset, req.user.user_id, ip);
|
||||
|
||||
// For image/video assets with a thumbnail — presign it so the browser can
|
||||
// load it directly from Garage without going through the stream proxy.
|
||||
if (asset.thumbnail_storage_key) {
|
||||
try {
|
||||
thumbnails[String(asset.asset_id)] = await s3.getSignedDownloadUrl(
|
||||
asset.thumbnail_storage_key,
|
||||
TOKEN_TTL_SEC,
|
||||
);
|
||||
} catch {
|
||||
// Non-fatal — stream token is the fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(assets.map(async (asset) => {
|
||||
const { token, thumbnail_url } = await mediaToken.issueForAsset(asset, req.user.user_id, ip);
|
||||
tokens[String(asset.asset_id)] = token;
|
||||
if (thumbnail_url) thumbnails[String(asset.asset_id)] = thumbnail_url;
|
||||
}));
|
||||
|
||||
return R.success(res, "Tokens issued.", { tokens, thumbnails });
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
// controllers/admin/notificationBroadcasts.controller.js
|
||||
|
||||
const sequelize = require("../../config/db.config");
|
||||
const NotificationBroadcast = require("../../models/notifications/notification_broadcast.mdl");
|
||||
const AdminNotification = require("../../models/notifications/admin_notification.mdl");
|
||||
const UserNotification = require("../../models/notifications/user_notification.mdl");
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { TaskList } = require('../../models/task/task.mdl');
|
||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const { Course } = require('../../models/courses/courses.mdl');
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/notifications/notification_broadcast.attributes");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const {
|
||||
ALLOWED_TARGET_TYPES,
|
||||
SCOPED_TARGET_TYPES,
|
||||
validateTargetId,
|
||||
resolveTaskListUserGroups,
|
||||
resolveTargetUserIds,
|
||||
} = require('../../utils/audienceResolver.util');
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
async function applyBroadcastFields(broadcast, body) {
|
||||
if (body.title !== undefined) broadcast.title = body.title;
|
||||
if (body.message !== undefined) broadcast.message = body.message;
|
||||
|
||||
if (body.target_type !== undefined) {
|
||||
if (!ALLOWED_TARGET_TYPES.includes(body.target_type)) {
|
||||
const err = new Error(`Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (SCOPED_TARGET_TYPES.includes(body.target_type)) {
|
||||
if (!body.target_id) {
|
||||
const err = new Error("target_id is required for this target_type.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
await validateTargetId(body.target_type, body.target_id);
|
||||
broadcast.target_id = String(body.target_id);
|
||||
} else {
|
||||
broadcast.target_id = null;
|
||||
}
|
||||
|
||||
broadcast.target_type = body.target_type;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Target resolution ────────────────────────────────────────────────────────
|
||||
// task_list/course/tier_plan resolution now lives in utils/audienceResolver.util.js
|
||||
// (resolveTargetUserIds, resolveTaskListUserGroups — imported above) so email
|
||||
// broadcasts resolve the same targets identically.
|
||||
|
||||
// Enrich one or many broadcast rows with a human-readable target_label.
|
||||
async function attachTargetLabels(rows) {
|
||||
const list = Array.isArray(rows) ? rows : [rows];
|
||||
const idsByType = { task_list: [], course: [], tier_plan: [] };
|
||||
list.forEach((r) => { if (SCOPED_TARGET_TYPES.includes(r.target_type) && r.target_id) idsByType[r.target_type].push(r.target_id); });
|
||||
|
||||
const [taskLists, courses, plans] = await Promise.all([
|
||||
idsByType.task_list.length ? TaskList.findAll({ where: { task_list_id: { [Op.in]: idsByType.task_list } }, attributes: ['task_list_id', 'name'], paranoid: false }) : [],
|
||||
idsByType.course.length ? Course.findAll({ where: { uuid: { [Op.in]: idsByType.course } }, attributes: ['uuid', 'title'], paranoid: false }) : [],
|
||||
idsByType.tier_plan.length ? mdl_TierPlans.findAll({ where: { plan_id: { [Op.in]: idsByType.tier_plan } }, attributes: ['plan_id', 'label'], paranoid: false }) : [],
|
||||
]);
|
||||
|
||||
const taskListMap = Object.fromEntries(taskLists.map((t) => [t.task_list_id, t.name]));
|
||||
const courseMap = Object.fromEntries(courses.map((c) => [c.uuid, c.title]));
|
||||
const planMap = Object.fromEntries(plans.map((p) => [String(p.plan_id), p.label]));
|
||||
|
||||
list.forEach((r) => {
|
||||
if (r.target_type === 'task_list') r.target_label = taskListMap[r.target_id] ?? null;
|
||||
else if (r.target_type === 'course') r.target_label = courseMap[r.target_id] ?? null;
|
||||
else if (r.target_type === 'tier_plan') r.target_label = planMap[r.target_id] ?? null;
|
||||
else r.target_label = null;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(NotificationBroadcast, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
|
||||
findOptions: { where: { ...notDeleted } },
|
||||
});
|
||||
|
||||
if (Array.isArray(result?.data)) await attachTargetLabels(result.data);
|
||||
|
||||
return R.success(res, "Notification broadcasts retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve notification broadcasts.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({
|
||||
where: { broadcast_id: broadcastId, ...notDeleted },
|
||||
include: [
|
||||
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
|
||||
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
|
||||
],
|
||||
});
|
||||
|
||||
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||
|
||||
const json = broadcast.toJSON();
|
||||
|
||||
if (json.creator) {
|
||||
json.creator = {
|
||||
user_id: json.creator.user_id,
|
||||
full_name: json.creator.personal_info?.name?.full_name ?? null,
|
||||
};
|
||||
}
|
||||
if (json.updater) {
|
||||
json.updater = {
|
||||
user_id: json.updater.user_id,
|
||||
full_name: json.updater.personal_info?.name?.full_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
await attachTargetLabels(json);
|
||||
|
||||
return R.success(res, "Notification broadcast retrieved.", { data: json });
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][GET ONE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.createBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { title, message, target_type, target_id, createdBy } = req.body;
|
||||
|
||||
if (!title) return R.error(res, "title is required.", 400);
|
||||
if (!message) return R.error(res, "message is required.", 400);
|
||||
if (!target_type) return R.error(res, "target_type is required.", 400);
|
||||
if (!ALLOWED_TARGET_TYPES.includes(target_type)) return R.error(res, `Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`, 400);
|
||||
if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, "target_id is required for this target_type.", 400);
|
||||
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
||||
|
||||
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const broadcast = await NotificationBroadcast.build({
|
||||
title, message, createdBy, status: 'draft',
|
||||
target_type,
|
||||
target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null,
|
||||
});
|
||||
await broadcast.save({ transaction: t });
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'create_notification_broadcast', { entityType: 'notification_broadcast', entityId: broadcast.broadcast_id, details: { target_type } });
|
||||
return R.success(res, "Notification broadcast created.", { data: broadcast }, 201);
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* connection gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][CREATE]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
||||
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||
|
||||
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be edited.", 400);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await applyBroadcastFields(broadcast, req.body);
|
||||
broadcast.updatedBy = req.body.updatedBy ?? null;
|
||||
await broadcast.save({ transaction: t });
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Notification broadcast updated.", { data: broadcast });
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][UPDATE]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── SEND ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.sendBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
||||
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be sent.", 400);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const now = new Date();
|
||||
let recipientCount = 0;
|
||||
|
||||
const targetType = broadcast.target_type;
|
||||
const targetId = broadcast.target_id;
|
||||
|
||||
const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({
|
||||
title: broadcast.title,
|
||||
message: broadcast.message,
|
||||
targetType,
|
||||
targetId,
|
||||
});
|
||||
|
||||
if (targetType === 'admin' || targetType === 'both') {
|
||||
await AdminNotification.create({ ...baseNotify, seen: false }, { transaction: t });
|
||||
recipientCount += 1;
|
||||
}
|
||||
|
||||
let userIds = [];
|
||||
let groupByUser = {}; // only populated for task_list — one group_id per user, for deep-linking
|
||||
|
||||
if (targetType === 'user' || targetType === 'both') {
|
||||
const users = await mdl_Users.findAll({
|
||||
attributes: ['user_id'],
|
||||
where: { acc_type: 'user', deletedAt: null },
|
||||
raw: true,
|
||||
transaction: t,
|
||||
});
|
||||
userIds = users.map((u) => String(u.user_id));
|
||||
} else if (targetType === 'task_list') {
|
||||
groupByUser = await resolveTaskListUserGroups(targetId);
|
||||
userIds = Object.keys(groupByUser);
|
||||
} else if (SCOPED_TARGET_TYPES.includes(targetType)) {
|
||||
userIds = await resolveTargetUserIds(targetType, targetId);
|
||||
}
|
||||
|
||||
if (userIds.length) {
|
||||
await UserNotification.bulkCreate(
|
||||
userIds.map((user_id) => ({
|
||||
user_id,
|
||||
...(targetType === 'task_list'
|
||||
? NOTIFICATION_REGISTRY.broadcast.build({
|
||||
title: broadcast.title, message: broadcast.message, targetType, targetId,
|
||||
groupId: groupByUser[user_id] ?? null,
|
||||
})
|
||||
: baseNotify),
|
||||
seen: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
{ validate: false, transaction: t }
|
||||
);
|
||||
}
|
||||
recipientCount += userIds.length;
|
||||
|
||||
broadcast.status = 'sent';
|
||||
broadcast.sent_at = now;
|
||||
broadcast.recipient_count = recipientCount;
|
||||
await broadcast.save({ transaction: t });
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'send_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId), details: { target_type: targetType, target_id: broadcast.target_id, recipient_count: recipientCount } });
|
||||
return R.success(res, "Notification broadcast sent.", { data: broadcast });
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][SEND]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
||||
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||
|
||||
await broadcast.update({ deletedBy: req.body.deletedBy ?? null });
|
||||
await broadcast.destroy();
|
||||
logActivity(req.user?.user_id, 'archive_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Notification broadcast archived.");
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const { ids, deletedBy } = req.body;
|
||||
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 }, ...notDeleted } });
|
||||
if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404);
|
||||
|
||||
const activeIds = broadcasts.map((b) => b.broadcast_id);
|
||||
|
||||
await NotificationBroadcast.update({ deletedBy: deletedBy ?? null }, { where: { broadcast_id: { [Op.in]: activeIds } } });
|
||||
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: activeIds } } });
|
||||
|
||||
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.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][BULK ARCHIVE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
|
||||
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||
if (!broadcast.deletedAt) return R.error(res, "Notification broadcast is not archived.", 400);
|
||||
|
||||
await broadcast.restore();
|
||||
await broadcast.update({ deletedBy: null });
|
||||
logActivity(req.user?.user_id, 'restore_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Notification broadcast restored.", { data: broadcast });
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][RESTORE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404);
|
||||
|
||||
const archived = broadcasts.filter((b) => b.deletedAt);
|
||||
if (!archived.length) return R.error(res, "All selected notification broadcasts are already active.", 400);
|
||||
|
||||
const archivedIds = archived.map((b) => b.broadcast_id);
|
||||
|
||||
await NotificationBroadcast.restore({ where: { broadcast_id: { [Op.in]: archivedIds } } });
|
||||
await NotificationBroadcast.update({ deletedBy: null }, { where: { broadcast_id: { [Op.in]: archivedIds } }, paranoid: false });
|
||||
|
||||
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.`, {
|
||||
restored_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][BULK RESTORE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(NotificationBroadcast, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
|
||||
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
||||
});
|
||||
return R.success(res, "Archived notification broadcasts retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][GET ARCHIVED]", err);
|
||||
return R.error(res, "Could not retrieve archived notification broadcasts.", 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
// controllers/admin/notificationSettings.controller.js
|
||||
|
||||
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { CRON_PRESETS, CRON_PRESET_BY_EXPRESSION } = require('../../data/cronPresets.data');
|
||||
const { rescheduleJob, getOrCreateSetting } = require('../../cron/cronRegistry.util');
|
||||
|
||||
// ─── Job registry — which cron scope owns each job (for defaults + labels) ────
|
||||
const JOBS = {
|
||||
taskOverdue: { schedule: '0 * * * *', label: 'Task Overdue Alerts (Admin)', description: 'Notifies admins when tasks flip to overdue.' },
|
||||
userNotifications: { schedule: '5 * * * *', label: 'Task Overdue Alerts (Users)', description: 'Notifies affected users when their tasks are marked overdue.' },
|
||||
issueCertificates: { schedule: '0 * * * *', label: 'Certificate Issued', description: 'Notifies users when a course certificate is ready.' },
|
||||
expireUserTiers: { schedule: '* * * * *', label: 'Tier Expired', description: 'Notifies users when their subscription tier expires.' },
|
||||
};
|
||||
|
||||
// ─── GET ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getSettings = async (req, res) => {
|
||||
try {
|
||||
const rows = [];
|
||||
for (const [job_name, meta] of Object.entries(JOBS)) {
|
||||
const row = await getOrCreateSetting(job_name, meta.schedule);
|
||||
rows.push({
|
||||
job_name,
|
||||
enabled: row.enabled,
|
||||
schedule: row.schedule,
|
||||
preset: CRON_PRESET_BY_EXPRESSION[row.schedule] ?? null,
|
||||
label: meta.label,
|
||||
description: meta.description,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return R.success(res, 'Notification settings retrieved.', rows);
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION SETTINGS][GET]', err);
|
||||
return R.error(res, 'Could not retrieve notification settings.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateSetting = async (req, res) => {
|
||||
try {
|
||||
const { jobName } = req.params;
|
||||
const { enabled, preset, updatedBy } = req.body;
|
||||
|
||||
if (!JOBS[jobName]) return R.error(res, `Unknown job "${jobName}".`, 404);
|
||||
|
||||
const row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
|
||||
if (!row) return R.error(res, 'Setting not found.', 404);
|
||||
|
||||
if (enabled !== undefined) row.enabled = enabled === true || enabled === 'true';
|
||||
|
||||
if (preset !== undefined) {
|
||||
const schedule = CRON_PRESETS[preset];
|
||||
if (!schedule) return R.error(res, `Invalid preset. Must be one of: ${Object.keys(CRON_PRESETS).join(', ')}`, 400);
|
||||
row.schedule = schedule;
|
||||
|
||||
try {
|
||||
rescheduleJob(jobName, schedule);
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION SETTINGS][RESCHEDULE]', err);
|
||||
return R.error(res, `Saved, but failed to reschedule the live job: ${err.message}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
row.updatedBy = updatedBy ?? null;
|
||||
await row.save();
|
||||
|
||||
logActivity(req.user?.user_id, 'update_notification_setting', { entityType: 'cron_notification_setting', entityId: jobName, details: { enabled: row.enabled, schedule: row.schedule } });
|
||||
return R.success(res, 'Notification setting updated.', { data: row });
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION SETTINGS][UPDATE]', err);
|
||||
return R.error(res, 'Internal server error.', 500);
|
||||
}
|
||||
};
|
||||
@@ -1,139 +0,0 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: plan_prices.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin CRUD for localized price overrides per tier plan.
|
||||
* Routes: GET/POST/PUT/DELETE /admin/tiers/:id/prices[/:currency]
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 29, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const mdl_PlanPrices = require('../../models/tiers/plan_prices.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { isSupported, SUPPORTED_CURRENCIES, validateLocalizedPrice } = require('../../utils/currency.util');
|
||||
|
||||
// ─── GET /admin/tiers/:id/prices ─────────────────────────────────────────────
|
||||
|
||||
exports.getPrices = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findByPk(req.params.id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
const prices = await mdl_PlanPrices.findAll({
|
||||
where: { plan_id: plan.plan_id },
|
||||
order: [['currency', 'ASC']],
|
||||
});
|
||||
|
||||
return R.success(res, 'Localized prices retrieved.', prices);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN PRICES]', err);
|
||||
return R.error(res, 'Could not retrieve localized prices.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/tiers/:id/prices ────────────────────────────────────────────
|
||||
|
||||
exports.addPrice = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findByPk(req.params.id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
const { currency, price } = req.body;
|
||||
if (!currency || price === undefined) return R.error(res, 'currency and price are required.', 400);
|
||||
if (!isSupported(currency)) return R.error(res, `Unsupported currency: ${currency}.`, 400);
|
||||
if (currency === plan.currency) return R.error(res, `${currency} is already the plan's base currency.`, 400);
|
||||
if (Number(price) < 0) return R.error(res, 'Price must be 0 or greater.', 400);
|
||||
|
||||
const exists = await mdl_PlanPrices.findOne({ where: { plan_id: plan.plan_id, currency } });
|
||||
if (exists) return R.error(res, `A localized price for ${currency} already exists. Use PUT to update it.`, 409);
|
||||
|
||||
// ── Rate validation ────────────────────────────────────────────────────────
|
||||
const validation = await validateLocalizedPrice(plan.price, plan.currency, price, currency);
|
||||
if (validation.zone === 'block') return R.error(res, validation.message, 422);
|
||||
|
||||
const entry = await mdl_PlanPrices.create({
|
||||
plan_id: plan.plan_id,
|
||||
currency: currency.toUpperCase(),
|
||||
price: Number(price),
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'add_plan_price', {
|
||||
entityType: 'plan_price',
|
||||
details: { plan_id: plan.plan_id, currency, price },
|
||||
});
|
||||
|
||||
if (validation.zone === 'warn')
|
||||
return res.status(201).json({ success: true, warning: true, message: validation.message, data: entry });
|
||||
|
||||
return R.success(res, 'Localized price added.', entry, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][ADD PLAN PRICE]', err);
|
||||
return R.error(res, 'Could not add localized price.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT /admin/tiers/:id/prices/:currency ───────────────────────────────────
|
||||
|
||||
exports.updatePrice = async (req, res) => {
|
||||
try {
|
||||
const { id, currency } = req.params;
|
||||
const { price } = req.body;
|
||||
|
||||
if (price === undefined) return R.error(res, 'price is required.', 400);
|
||||
if (Number(price) < 0) return R.error(res, 'Price must be 0 or greater.', 400);
|
||||
|
||||
const entry = await mdl_PlanPrices.findOne({ where: { plan_id: id, currency: currency.toUpperCase() } });
|
||||
if (!entry) return R.error(res, `No localized price found for ${currency} on this plan.`, 404);
|
||||
|
||||
// ── Rate validation ────────────────────────────────────────────────────────
|
||||
const plan = await mdl_TierPlans.findByPk(id);
|
||||
const validation = await validateLocalizedPrice(plan.price, plan.currency, price, currency);
|
||||
if (validation.zone === 'block') return R.error(res, validation.message, 422);
|
||||
|
||||
await entry.update({ price: Number(price) });
|
||||
|
||||
logActivity(req.user?.user_id, 'update_plan_price', {
|
||||
entityType: 'plan_price',
|
||||
details: { plan_id: id, currency, price },
|
||||
});
|
||||
|
||||
if (validation.zone === 'warn')
|
||||
return res.status(200).json({ success: true, warning: true, message: validation.message, data: entry });
|
||||
|
||||
return R.success(res, 'Localized price updated.', entry);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPDATE PLAN PRICE]', err);
|
||||
return R.error(res, 'Could not update localized price.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE /admin/tiers/:id/prices/:currency ────────────────────────────────
|
||||
|
||||
exports.removePrice = async (req, res) => {
|
||||
try {
|
||||
const { id, currency } = req.params;
|
||||
|
||||
const entry = await mdl_PlanPrices.findOne({ where: { plan_id: id, currency: currency.toUpperCase() } });
|
||||
if (!entry) return R.error(res, `No localized price found for ${currency} on this plan.`, 404);
|
||||
|
||||
await entry.destroy();
|
||||
|
||||
logActivity(req.user?.user_id, 'remove_plan_price', {
|
||||
entityType: 'plan_price',
|
||||
details: { plan_id: id, currency },
|
||||
});
|
||||
|
||||
return R.success(res, 'Localized price removed.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][REMOVE PLAN PRICE]', err);
|
||||
return R.error(res, 'Could not remove localized price.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /admin/currencies ────────────────────────────────────────────────────
|
||||
|
||||
exports.getCurrencies = async (_req, res) => {
|
||||
return R.success(res, 'Supported currencies retrieved.', SUPPORTED_CURRENCIES);
|
||||
};
|
||||
@@ -11,7 +11,10 @@ const { Op, Sequelize } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
|
||||
const { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups } = require('../../models/task/task.mdl');
|
||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { adminExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes');
|
||||
|
||||
const R = require('../../utils/response.util');
|
||||
@@ -623,6 +626,53 @@ exports.updateTask = async (req, res) => {
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'update_task', { entityType: 'task', entityId: Number(taskId) });
|
||||
|
||||
// ── Notify assigned users when requirements changed ────────────────────
|
||||
if (Array.isArray(requirements)) {
|
||||
try {
|
||||
const groupRows = await TaskListGroup.findAll({
|
||||
where: { task_list_id: task.task_list_id },
|
||||
attributes: ['group_id'],
|
||||
});
|
||||
const groupIds = groupRows.map((r) => r.group_id);
|
||||
|
||||
if (groupIds.length) {
|
||||
const memberRows = await mdl_UserGroupMembers.findAll({
|
||||
where: { group_id: groupIds },
|
||||
attributes: ['user_id', 'group_id'],
|
||||
});
|
||||
|
||||
// One notification per user — first group membership wins if they're in more than one.
|
||||
const seenUsers = new Set();
|
||||
const members = memberRows.filter(({ user_id }) => {
|
||||
if (seenUsers.has(user_id)) return false;
|
||||
seenUsers.add(user_id);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (members.length) {
|
||||
const now = new Date();
|
||||
await UserNotification.bulkCreate(
|
||||
members.map(({ user_id, group_id }) => ({
|
||||
user_id,
|
||||
...NOTIFICATION_REGISTRY.task_requirements_updated.build({
|
||||
taskName: full.name,
|
||||
taskListId: task.task_list_id,
|
||||
groupId: group_id,
|
||||
}),
|
||||
seen: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
{ validate: false }
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
console.error('[ADMIN][UPDATE TASK][NOTIFY]', notifyErr);
|
||||
}
|
||||
}
|
||||
|
||||
return R.success(res, 'Task updated successfully.', full);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
|
||||
@@ -37,8 +37,20 @@ const {
|
||||
computedAttributes: paymentsComputed,
|
||||
} = require('../../models/tiers/payments.attributes');
|
||||
|
||||
const cc = require('currency-codes');
|
||||
|
||||
const PENDING_PAYMENT_EXPIRY_MINUTES = 60;
|
||||
|
||||
// ─── CURRENCIES ───────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getCurrencies = (req, res) => {
|
||||
const list = cc.codes().map((code) => {
|
||||
const entry = cc.code(code);
|
||||
return { code: entry.code, name: entry.currency };
|
||||
}).sort((a, b) => a.code.localeCompare(b.code));
|
||||
return R.success(res, 'OK', list);
|
||||
};
|
||||
|
||||
const expireStalePendingPayments = async () => {
|
||||
const expiresBefore = new Date(Date.now() - PENDING_PAYMENT_EXPIRY_MINUTES * 60 * 1000);
|
||||
await mdl_Payments.update(
|
||||
|
||||
@@ -37,6 +37,14 @@ exports.getUnits = async (req, res) => {
|
||||
where: { course_id: courseId, ...notDeleted },
|
||||
order: [["order_index", "ASC"]],
|
||||
},
|
||||
computedAttributes: [
|
||||
{
|
||||
key: "quiz_id",
|
||||
label: "Quiz ID",
|
||||
type: "text",
|
||||
literal: `(SELECT quiz_id FROM unit_quizzes WHERE unit_id = "Unit"."unit_id" AND "deletedAt" IS NULL LIMIT 1)`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return R.success(res, "Units retrieved.", result);
|
||||
|
||||
Reference in New Issue
Block a user