new commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:20:58 +08:00
parent 1372f4e975
commit 1d12f04967
93 changed files with 3849 additions and 1063 deletions
@@ -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);
}
};
+50 -15
View File
@@ -4,9 +4,11 @@ const sequelize = require("../../config/db.config");
const Advertisement = require("../../models/advertisements/advertisements.mdl"); const Advertisement = require("../../models/advertisements/advertisements.mdl");
const mdl_Assets = require("../../models/assets/assets.mdl"); const mdl_Assets = require("../../models/assets/assets.mdl");
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const mediaToken = require("../../services/mediaToken.service");
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util"); const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/advertisements/advertisements.attributes"); 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 { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
@@ -16,9 +18,32 @@ const { Op } = require('sequelize');
const notDeleted = { deletedAt: null }; const notDeleted = { deletedAt: null };
const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"];
const ALLOWED_STATUSES = ["draft", "active", "scheduled", "expired", "archived"]; 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 derivation ─────────────────────────────────────────────────────
// status is never trusted as manually-set truth — it's derived from is_active // 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. // + start_date/end_date every time an advertisement is read or written.
@@ -53,13 +78,17 @@ function normalizeCtas(ctas) {
} }
async function applyAdvertisementFields(advertisement, body) { async function applyAdvertisementFields(advertisement, body) {
if (body.type !== undefined) { // placement is the only settable "where" — type/format is always derived
if (!ALLOWED_TYPES.includes(body.type)) { // from the placement's registry entry, never accepted directly from the body.
const err = new Error(`Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`); 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; err.status = 400;
throw err; 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() // 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.is_active !== undefined) advertisement.is_active = body.is_active === true || body.is_active === "true";
if (body.size !== undefined) { if (body.size !== undefined) {
if (body.size !== null && !["sm", "md", "lg"].includes(body.size)) { if (body.size !== null && !["sm", "md", "lg", "xl"].includes(body.size)) {
const err = new Error(`Invalid size. Must be one of: sm, md, lg`); const err = new Error(`Invalid size. Must be one of: sm, md, lg, xl`);
err.status = 400; err.status = 400;
throw err; throw err;
} }
@@ -120,7 +149,7 @@ exports.getAdvertisements = async (req, res) => {
include: [{ include: [{
model: mdl_Assets, model: mdl_Assets,
as: "image", as: "image",
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"], attributes: AD_IMAGE_ATTRIBUTES,
required: false, required: false,
}], }],
}, },
@@ -129,7 +158,10 @@ exports.getAdvertisements = async (req, res) => {
// Resync status on the way out — never trust what's stored, since // 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. // start_date/end_date may have lapsed since the row was last saved.
if (Array.isArray(result?.data)) { 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); return R.success(res, "Advertisements retrieved.", result);
@@ -149,7 +181,7 @@ exports.getAdvertisement = async (req, res) => {
const advertisement = await Advertisement.findOne({ const advertisement = await Advertisement.findOne({
where: { advertisement_id: advertisementId, ...notDeleted }, where: { advertisement_id: advertisementId, ...notDeleted },
include: [ 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: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" }, { model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
], ],
@@ -160,6 +192,8 @@ exports.getAdvertisement = async (req, res) => {
const json = advertisement.toJSON(); const json = advertisement.toJSON();
json.status = deriveStatus(json); json.status = deriveStatus(json);
if (json.image) await attachImageStreamToken(json.image, req);
if (json.creator) { if (json.creator) {
json.creator = { json.creator = {
user_id: json.creator.user_id, user_id: json.creator.user_id,
@@ -184,20 +218,21 @@ exports.getAdvertisement = async (req, res) => {
exports.createAdvertisement = async (req, res) => { exports.createAdvertisement = async (req, res) => {
try { try {
const { type, createdBy } = req.body; const { placement, createdBy } = req.body;
if (!type) return R.error(res, "type is required.", 400); if (!placement) return R.error(res, "placement is required.", 400);
if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 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); if (!createdBy) return R.error(res, "createdBy is required.", 400);
const t = await sequelize.transaction(); const t = await sequelize.transaction();
try { try {
const advertisement = await Advertisement.build({ type, createdBy }); const advertisement = await Advertisement.build({ placement, type: entry.format, createdBy });
await applyAdvertisementFields(advertisement, req.body); await applyAdvertisementFields(advertisement, req.body);
await advertisement.save({ transaction: t }); await advertisement.save({ transaction: t });
await t.commit(); 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); return R.success(res, "Advertisement created.", { data: advertisement }, 201);
} catch (dbErr) { } catch (dbErr) {
try { await t.rollback(); } catch { /* connection gone */ } try { await t.rollback(); } catch { /* connection gone */ }
+87 -4
View File
@@ -6,6 +6,7 @@ const sequelize = require("../../config/db.config");
const Asset = require("../../models/assets/assets.mdl"); const Asset = require("../../models/assets/assets.mdl");
const chibi = require("../../services/chibisafe.service"); const chibi = require("../../services/chibisafe.service");
const s3 = require("../../services/s3.service"); const s3 = require("../../services/s3.service");
const mediaToken = require("../../services/mediaToken.service");
const { extractVideoMeta } = require("../../services/ffprobe.service"); const { extractVideoMeta } = require("../../services/ffprobe.service");
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util"); const { paginate } = require("../../utils/paginate.util");
@@ -20,6 +21,29 @@ const { Op } = require('sequelize');
const notDeleted = { deletedAt: null }; 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 = "") { function resolveFileType(mimeType = "") {
if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("video/")) return "video";
@@ -166,12 +190,51 @@ function redactS3Url(asset) {
return 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 ────────────────────────────────────────────────────────────────── // ─── GET ALL ──────────────────────────────────────────────────────────────────
exports.getAssets = async (req, res) => { exports.getAssets = async (req, res) => {
try { try {
const result = await paginate(Asset, req, { const key = listCacheKey(req);
excludeAttributes: adminExclude, 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, jsonbSchemas,
computedAttributes, computedAttributes,
context: "list", context: "list",
@@ -179,7 +242,11 @@ exports.getAssets = async (req, res) => {
findOptions: { where: { deletedAt: null } }, findOptions: { where: { deletedAt: null } },
}); });
result.data = result.data.map(redactS3Url); result.data = result.data.map(redactS3Url);
return R.success(res, "Assets retrieved.", result); 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) { } catch (err) {
console.error("[ASSET][GET ALL]", err); console.error("[ASSET][GET ALL]", err);
return R.error(res, "Could not retrieve assets.", 500); return R.error(res, "Could not retrieve assets.", 500);
@@ -195,7 +262,9 @@ exports.getAsset = async (req, res) => {
const asset = await Asset.findOne({ const asset = await Asset.findOne({
where: { asset_id: assetId, ...notDeleted }, 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: [ include: [
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" }, { model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" }, { model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
@@ -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); redactS3Url(json);
return R.success(res, "Asset retrieved.", { data: json }); return R.success(res, "Asset retrieved.", { data: json });
} catch (err) { } catch (err) {
@@ -404,6 +481,7 @@ exports.uploadAsset = async (req, res) => {
}, { transaction: t }); }, { transaction: t });
await t.commit(); 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 } }); 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); 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); if (uploaded) await deleteOldFile(storageProvider, oldStorageKey, uploaded.storage_key);
invalidateListCache();
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) }); logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "Asset updated.", { data: asset }); 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.update({ deletedBy: req.body.deletedBy ?? null });
await asset.destroy(); await asset.destroy();
invalidateListCache();
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) }); logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "Asset archived."); return R.success(res, "Asset archived.");
} catch (err) { } 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.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } });
await Asset.destroy({ 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 } }); 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.`, { return R.success(res, `${activeIds.length} asset(s) archived.`, {
archived_ids: activeIds, archived_ids: activeIds,
@@ -540,6 +621,7 @@ exports.restoreAsset = async (req, res) => {
await asset.restore(); await asset.restore();
await asset.update({ deletedBy: null }); await asset.update({ deletedBy: null });
invalidateListCache();
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) }); logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "Asset restored.", { data: asset }); return R.success(res, "Asset restored.", { data: asset });
} catch (err) { } catch (err) {
@@ -566,6 +648,7 @@ exports.restoreAssets = async (req, res) => {
await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } }); await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } });
await Asset.update({ deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false }); 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 } }); 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.`, { return R.success(res, `${archivedIds.length} asset(s) restored.`, {
restored_ids: archivedIds, restored_ids: archivedIds,
+172 -11
View File
@@ -24,10 +24,13 @@ const {
UnitQuiz, QuizQuestion, QuizOption, UnitQuiz, QuizQuestion, QuizOption,
QuizAttempt, AssessmentSession, QuizAttempt, AssessmentSession,
CourseInstructor, CourseAchievement, CourseInstructor, CourseAchievement,
UnitReadingProgress, LessonReadingProgress,
} = require("../../models/courses/courses.associations"); } = require("../../models/courses/courses.associations");
const mdl_Users = require("../../models/users/users.mdl"); const mdl_Users = require("../../models/users/users.mdl");
const { mdl_PlanCourses, mdl_TierPlans } = require("../../models/tiers/tier.associations");
const { const {
excludeAttributes: courseExclude, excludeAttributes: courseExclude,
computedAttributes: courseComputed, computedAttributes: courseComputed,
@@ -46,7 +49,15 @@ exports.getCourses = async (req, res) => {
try { try {
const result = await paginate(Course, req, { const result = await paginate(Course, req, {
excludeAttributes: courseExclude, 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" }, auditOptions: { mdl_Users, parentAlias: "Course" },
context: "list", context: "list",
findOptions: { where: { ...notDeleted } }, findOptions: { where: { ...notDeleted } },
@@ -126,7 +137,7 @@ exports.createCourse = async (req, res) => {
if (achievement_keys.length) { if (achievement_keys.length) {
await CourseAchievement.bulkCreate( 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 }, { 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 // 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) => { exports.getUnit = async (req, res) => {
try { try {
const { courseId, unitId } = req.params; 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 // COURSE ASSESSMENT
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
@@ -1341,11 +1473,12 @@ exports.updateAssessment = async (req, res) => {
const course = await Course.findOne({ const course = await Course.findOne({
where: { course_id: courseId }, where: { course_id: courseId },
attributes: ['title'], attributes: ['title', 'uuid'],
}); });
const notify = NOTIFICATION_REGISTRY.assessment_updated.build({ const notify = NOTIFICATION_REGISTRY.assessment_updated.build({
assessmentTitle: assessment.title, assessmentTitle: assessment.title,
courseTitle: course?.title ?? null, courseTitle: course?.title ?? null,
courseUuid: course?.uuid ?? null,
}); });
const now = new Date(); const now = new Date();
await UserNotification.bulkCreate( await UserNotification.bulkCreate(
@@ -1433,7 +1566,7 @@ exports.getCoursesFlat = async (req, res) => {
try { try {
const data = await Course.findAll({ const data = await Course.findAll({
where: notDeleted, where: notDeleted,
attributes: ["uuid", "title"], attributes: ["uuid", "title", "subscription", "duration_seconds"],
order: [["title", "ASC"]], order: [["title", "ASC"]],
}); });
return R.success(res, "Courses retrieved.", data); return R.success(res, "Courses retrieved.", data);
@@ -1448,11 +1581,32 @@ exports.getCoursesBySubscription = async (req, res) => {
const { slug } = req.query; const { slug } = req.query;
if (!slug) return R.error(res, 'slug query param is required.', 400); 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 }, where: { ...notDeleted, subscription: slug },
attributes: ['course_id', 'title', 'description', 'subscription'], 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']], 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); return R.success(res, 'Courses retrieved.', data);
} catch (err) { } catch (err) {
console.error('[COURSE][BY SUBSCRIPTION]', err); console.error('[COURSE][BY SUBSCRIPTION]', err);
@@ -1464,11 +1618,12 @@ exports.getUnitsFlat = async (req, res) => {
try { try {
const rows = await Unit.findAll({ const rows = await Unit.findAll({
where: notDeleted, where: notDeleted,
attributes: ["uuid", "title", "order_index"], attributes: ["uuid", "title", "order_index", "duration_seconds"],
include: [{ include: [{
model: Course, model: Course,
as: "course", as: "course",
attributes: ["title"], attributes: ["title", "subscription"],
paranoid: false,
}], }],
order: [ order: [
[{ model: Course, as: "course" }, "title", "ASC"], [{ model: Course, as: "course" }, "title", "ASC"],
@@ -1479,7 +1634,9 @@ exports.getUnitsFlat = async (req, res) => {
uuid: u.uuid, uuid: u.uuid,
title: u.title, title: u.title,
order_index: u.order_index ?? 0, order_index: u.order_index ?? 0,
duration_seconds: u.duration_seconds ?? 0,
course_title: u.course?.title ?? "", course_title: u.course?.title ?? "",
subscription: u.course?.subscription ?? "free",
})); }));
return R.success(res, "Units retrieved.", data); return R.success(res, "Units retrieved.", data);
} catch (err) { } catch (err) {
@@ -1492,15 +1649,17 @@ exports.getLessonsFlat = async (req, res) => {
try { try {
const rows = await Lesson.findAll({ const rows = await Lesson.findAll({
where: notDeleted, where: notDeleted,
attributes: ["uuid", "title", "order_index"], attributes: ["uuid", "title", "order_index", "duration_seconds"],
include: [{ include: [{
model: Unit, model: Unit,
as: "unit", as: "unit",
attributes: ["title", "order_index"], attributes: ["title", "order_index"],
paranoid: false,
include: [{ include: [{
model: Course, model: Course,
as: "course", as: "course",
attributes: ["title"], attributes: ["title", "subscription"],
paranoid: false,
}], }],
}], }],
order: [ order: [
@@ -1513,9 +1672,11 @@ exports.getLessonsFlat = async (req, res) => {
uuid: l.uuid, uuid: l.uuid,
title: l.title, title: l.title,
order_index: l.order_index ?? 0, order_index: l.order_index ?? 0,
duration_seconds: l.duration_seconds ?? 0,
unit_title: l.unit?.title ?? "", unit_title: l.unit?.title ?? "",
unit_order: l.unit?.order_index ?? 0, unit_order: l.unit?.order_index ?? 0,
course_title: l.unit?.course?.title ?? "", course_title: l.unit?.course?.title ?? "",
subscription: l.unit?.course?.subscription ?? "free",
})); }));
return R.success(res, "Lessons retrieved.", data); return R.success(res, "Lessons retrieved.", data);
} catch (err) { } catch (err) {
@@ -1801,8 +1962,8 @@ exports.syncCourseAchievements = async (req, res) => {
const { courseId } = req.params; const { courseId } = req.params;
const { achievement_keys = [] } = req.body; const { achievement_keys = [] } = req.body;
if (achievement_keys.length > 3) if (achievement_keys.length > 1)
return R.error(res, "Maximum 3 achievements allowed per course.", 400); return R.error(res, "Maximum 1 achievement allowed per course.", 400);
await CourseAchievement.destroy({ where: { course_id: courseId }, transaction: t }); 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 Derivation
Status is **never** trusted as stored — it is recomputed on every read and write: 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": { "data": {
"advertisement_id": 1, "advertisement_id": 1,
"uuid": "...", "uuid": "...",
"placement": "dashboard.hero",
"type": "hero", "type": "hero",
"status": "active", "status": "active",
"badge_label": "New", "badge_label": "New",
@@ -131,7 +153,7 @@ Returns one advertisement with its `image` asset and audit user info.
### Request Body ### Request Body
| Field | Type | Required | Description | | 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 | | `createdBy` | number | **Yes** | User ID of creator |
| `badge_label` | string | No | Small label shown on the ad | | `badge_label` | string | No | Small label shown on the ad |
| `headline` | string | No | Main heading | | `headline` | string | No | Main heading |
@@ -157,8 +179,8 @@ Returns one advertisement with its `image` asset and audit user info.
### Error Responses ### Error Responses
| Status | Message | | Status | Message |
|--------|---------| |--------|---------|
| `400` | `type is required.` | | `400` | `placement is required.` |
| `400` | `Invalid type. Must be one of: hero, banner, popup, sidebar` | | `400` | `Invalid placement. Must be one of: dashboard.hero, dashboard.popup, ...` |
| `400` | `createdBy is required.` | | `400` | `createdBy is required.` |
| `400` | `Invalid size. Must be one of: sm, md, lg` | | `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. Partial update. Only fields present in the body are changed. Status is recomputed after all fields are applied.
### Request Body ### 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` ### Response `200`
```json ```json
@@ -279,6 +301,10 @@ Returns distinct values for filterable advertisement fields. Used by DataTable f
{ {
"status": "success", "status": "success",
"message": "Field values retrieved.", "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);
}
};
+10 -58
View File
@@ -13,37 +13,10 @@
"use strict"; "use strict";
const { Op } = require("sequelize"); const { Op } = require("sequelize");
const jwt = require("jsonwebtoken");
const R = require("../../utils/response.util"); const R = require("../../utils/response.util");
const mdl_Assets = require("../../models/assets/assets.mdl"); const mdl_Assets = require("../../models/assets/assets.mdl");
const s3 = require("../../services/s3.service"); const mediaToken = require("../../services/mediaToken.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 }
);
}
// ─── POST /admin/media/token ────────────────────────────────────────────────── // ─── POST /admin/media/token ──────────────────────────────────────────────────
@@ -59,7 +32,7 @@ exports.issueToken = async (req, res) => {
if (!asset) return R.error(res, "Asset not found.", 404); 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); 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); 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 ip = mediaToken.resolveIp(req);
const token = signToken(asset, req.user.user_id, ip); const { token, thumbnail_url } = await mediaToken.issueForAsset(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
}
}
return R.success(res, "Token issued.", { return R.success(res, "Token issued.", {
token, token,
@@ -116,26 +79,15 @@ exports.issueTokensBatch = async (req, res) => {
attributes: ["asset_id", "file_type", "storage_key", "mime_type", "thumbnail_storage_key"], attributes: ["asset_id", "file_type", "storage_key", "mime_type", "thumbnail_storage_key"],
}); });
const ip = resolveIp(req); const ip = mediaToken.resolveIp(req);
const tokens = {}; const tokens = {};
const thumbnails = {}; const thumbnails = {};
for (const asset of assets) { await Promise.all(assets.map(async (asset) => {
tokens[String(asset.asset_id)] = signToken(asset, req.user.user_id, ip); const { token, thumbnail_url } = await mediaToken.issueForAsset(asset, req.user.user_id, ip);
tokens[String(asset.asset_id)] = token;
// For image/video assets with a thumbnail — presign it so the browser can if (thumbnail_url) thumbnails[String(asset.asset_id)] = thumbnail_url;
// 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
}
}
}
return R.success(res, "Tokens issued.", { tokens, thumbnails }); return R.success(res, "Tokens issued.", { tokens, thumbnails });
} catch (err) { } 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);
}
};
-139
View File
@@ -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);
};
+50
View File
@@ -11,7 +11,10 @@ const { Op, Sequelize } = require('sequelize');
const sequelize = require('../../config/db.config'); const sequelize = require('../../config/db.config');
const { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups } = require('../../models/task/task.mdl'); 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 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 { adminExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes');
const R = require('../../utils/response.util'); 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) }); 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); return R.success(res, 'Task updated successfully.', full);
} catch (err) { } catch (err) {
await t.rollback(); await t.rollback();
+12
View File
@@ -37,8 +37,20 @@ const {
computedAttributes: paymentsComputed, computedAttributes: paymentsComputed,
} = require('../../models/tiers/payments.attributes'); } = require('../../models/tiers/payments.attributes');
const cc = require('currency-codes');
const PENDING_PAYMENT_EXPIRY_MINUTES = 60; 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 expireStalePendingPayments = async () => {
const expiresBefore = new Date(Date.now() - PENDING_PAYMENT_EXPIRY_MINUTES * 60 * 1000); const expiresBefore = new Date(Date.now() - PENDING_PAYMENT_EXPIRY_MINUTES * 60 * 1000);
await mdl_Payments.update( await mdl_Payments.update(
+8
View File
@@ -37,6 +37,14 @@ exports.getUnits = async (req, res) => {
where: { course_id: courseId, ...notDeleted }, where: { course_id: courseId, ...notDeleted },
order: [["order_index", "ASC"]], 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); return R.success(res, "Units retrieved.", result);
+2 -1
View File
@@ -186,6 +186,7 @@ exports.verifyOTP = async (req, res) => {
groupName: grp?.name ?? null, groupName: grp?.name ?? null,
groupCode: grp?.group_code ?? null, groupCode: grp?.group_code ?? null,
accType: user.acc_type, accType: user.acc_type,
groupId: membership?.group_id ?? null,
}), }),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -407,7 +408,7 @@ exports.googleCallback = async (req, res) => {
UserNotification.bulkCreate([ UserNotification.bulkCreate([
{ {
user_id: user.user_id, user_id: user.user_id,
...NOTIFICATION_REGISTRY.welcome.build({ groupName: null, groupCode: 'NOGRP', accType: 'user' }), ...NOTIFICATION_REGISTRY.welcome.build({ groupName: null, groupCode: 'NOGRP', accType: 'user', groupId: noGrp?.group_id ?? null }),
createdAt: _now, createdAt: _now,
updatedAt: _now, updatedAt: _now,
}, },
+101 -27
View File
@@ -2,11 +2,39 @@
const Advertisement = require("../../models/advertisements/advertisements.mdl"); const Advertisement = require("../../models/advertisements/advertisements.mdl");
const mdl_Assets = require("../../models/assets/assets.mdl"); const mdl_Assets = require("../../models/assets/assets.mdl");
const mediaToken = require("../../services/mediaToken.service");
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { PLACEMENT_MAP } = require("../../models/advertisements/advertisements.placements");
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"]; const AD_IMAGE_INCLUDE = {
model: mdl_Assets,
as: "image",
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"],
required: false,
};
const AD_CLIENT_EXCLUDE = ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"];
// ─── Media proxying ─────────────────────────────────────────────────────────
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken.
// Kept duplicated rather than shared to avoid a cross-boundary import between
// admin and client controllers (same rationale as deriveStatus above). Private
// (S3-backed) images never expose a raw file_url — the frontend resolves the
// stream_token through GET /api/client/media/stream/:token instead.
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 derivation ─────────────────────────────────────────────────────
// Mirrors admin controller's deriveStatus — single source of truth for what // Mirrors admin controller's deriveStatus — single source of truth for what
@@ -25,42 +53,41 @@ function deriveStatus(advertisement) {
return "active"; return "active";
} }
// ─── GET ACTIVE ─────────────────────────────────────────────────────────────── // ─── Live window helper ─────────────────────────────────────────────────────
// // "Live" means is_active = true AND within start_date/end_date window —
// Resolves the single highest-priority live advertisement for a given placement
// type. "Live" means is_active = true AND within start_date/end_date window —
// computed the same way as deriveStatus, but expressed as a SQL WHERE clause // computed the same way as deriveStatus, but expressed as a SQL WHERE clause
// here since we want the DB to do the filtering/ordering, not JS. // here since we want the DB to do the filtering/ordering, not JS.
// function liveWhere(extra) {
// GET /api/client/advertisements/active?type=hero
//
exports.getActiveAdvertisement = async (req, res) => {
try {
const { type } = req.query;
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);
const now = new Date(); const now = new Date();
return {
const advertisement = await Advertisement.findOne({ ...extra,
where: {
type,
is_active: true, is_active: true,
deletedAt: null, deletedAt: null,
[Op.and]: [ [Op.and]: [
{ [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] }, { [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] },
{ [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] }, { [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] },
], ],
}, };
}
// ─── GET ACTIVE ───────────────────────────────────────────────────────────────
//
// Resolves the single highest-priority live advertisement for a given placement.
//
// GET /api/client/advertisements/active?placement=dashboard.hero
//
exports.getActiveAdvertisement = async (req, res) => {
try {
const { placement } = req.query;
if (!placement) return R.error(res, "placement is required.", 400);
if (!PLACEMENT_MAP[placement]) return R.error(res, "Invalid placement.", 400);
const advertisement = await Advertisement.findOne({
where: liveWhere({ placement }),
order: [["order", "ASC"], ["createdAt", "DESC"]], order: [["order", "ASC"], ["createdAt", "DESC"]],
include: [{ include: [AD_IMAGE_INCLUDE],
model: mdl_Assets, attributes: { exclude: AD_CLIENT_EXCLUDE },
as: "image",
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"],
required: false,
}],
attributes: { exclude: ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"] },
}); });
if (!advertisement) return R.success(res, "No active advertisement for this placement.", { data: null }); if (!advertisement) return R.success(res, "No active advertisement for this placement.", { data: null });
@@ -68,6 +95,8 @@ exports.getActiveAdvertisement = async (req, res) => {
const json = advertisement.toJSON(); const json = advertisement.toJSON();
json.status = deriveStatus(json); // will always be "active" given the WHERE clause, but kept for shape consistency json.status = deriveStatus(json); // will always be "active" given the WHERE clause, but kept for shape consistency
if (json.image) await attachImageStreamToken(json.image, req);
return R.success(res, "Active advertisement retrieved.", { data: json }); return R.success(res, "Active advertisement retrieved.", { data: json });
} catch (err) { } catch (err) {
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE]", err); console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE]", err);
@@ -75,6 +104,51 @@ exports.getActiveAdvertisement = async (req, res) => {
} }
}; };
// ─── GET ACTIVE (batch) ─────────────────────────────────────────────────────
//
// Resolves the highest-priority live advertisement for each of several
// placements in a single round-trip — pages that need multiple simultaneous
// slots (e.g. dashboard.hero + dashboard.popup) use this instead of N calls
// to /active.
//
// GET /api/client/advertisements/active-batch?placements=dashboard.hero,dashboard.popup
//
exports.getActiveAdvertisements = async (req, res) => {
try {
const raw = req.query.placements;
const placements = (Array.isArray(raw) ? raw : String(raw ?? "").split(","))
.map((p) => p.trim())
.filter(Boolean);
if (!placements.length) return R.error(res, "placements is required.", 400);
const invalid = placements.filter((p) => !PLACEMENT_MAP[p]);
if (invalid.length) return R.error(res, `Invalid placement(s): ${invalid.join(", ")}`, 400);
const advertisements = await Advertisement.findAll({
where: liveWhere({ placement: { [Op.in]: placements } }),
order: [["order", "ASC"], ["createdAt", "DESC"]],
include: [AD_IMAGE_INCLUDE],
attributes: { exclude: AD_CLIENT_EXCLUDE },
});
// Keep only the highest-priority row per placement (order ASC, createdAt DESC already applied).
const data = Object.fromEntries(placements.map((p) => [p, null]));
for (const ad of advertisements) {
const json = ad.toJSON();
if (data[json.placement] !== null) continue; // already have the winner for this placement
json.status = deriveStatus(json);
if (json.image) await attachImageStreamToken(json.image, req);
data[json.placement] = json;
}
return R.success(res, "Active advertisements retrieved.", { data });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE BATCH]", err);
return R.error(res, "Could not retrieve advertisements.", 500);
}
};
// ─── TRACK CLICK ────────────────────────────────────────────────────────────── // ─── TRACK CLICK ──────────────────────────────────────────────────────────────
// //
// POST /api/client/advertisements/:advertisementId/click // POST /api/client/advertisements/:advertisementId/click
+12 -70
View File
@@ -14,49 +14,17 @@
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const { generateCertificate } = require('../../services/certificate.service'); const { generateCertificate } = require('../../services/certificate.service');
const { formatDuration } = require('../../utils/duration.util'); const { ensureCertificateRecord, formatInstructors } = require('../../services/certificate-record.service');
const { fmtDate } = require('../../utils/datetime.util'); const { fmtDate } = require('../../utils/datetime.util');
const { const {
Course, Course,
CourseAssessment, CourseAssessment,
QuizAttempt,
Certificate,
CourseInstructor, CourseInstructor,
} = require('../../models/courses/courses.associations'); } = require('../../models/courses/courses.associations');
const notDeleted = { deletedAt: null }; const notDeleted = { deletedAt: null };
// ─── Helpers ───────────────────────────────────────────────────────────────────
// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5}
// userCertSeq = how many certs this user will have after this insert
async function buildCertNo(userId) {
const count = await Certificate.count({ where: { user_id: userId } });
const seq = String(count + 1).padStart(5, '0');
const uid = String(userId).padStart(6, '0');
const now = new Date();
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
return `${YYYYMM}-${uid}-${seq}`;
}
// ref_no format: PP-YYYYMM-{globalSeq:5} (unique across all certs)
async function buildRefNo() {
const count = await Certificate.count();
const seq = String(count + 1).padStart(5, '0');
const now = new Date();
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
return `PP-${YYYYMM}-${seq}`;
}
function formatInstructors(rows) {
const names = rows.map(r => r.display_name);
if (names.length === 0) return '';
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} and ${names[1]}`;
return `${names[0]}, ${names[1]} and et. al`;
}
// ─── GET /api/client/certificates/:courseUuid ────────────────────────────────── // ─── GET /api/client/certificates/:courseUuid ──────────────────────────────────
exports.getCertificate = async (req, res) => { exports.getCertificate = async (req, res) => {
@@ -91,53 +59,27 @@ exports.getCertificate = async (req, res) => {
return R.error(res, 'This course does not have an assessment — no certificate available.', 404); return R.error(res, 'This course does not have an assessment — no certificate available.', 404);
} }
// ── 2. Verify the user passed ────────────────────────────────────────────── // ── 2. Get user's name ──────────────────────────────────────────────────────
const passedAttempt = await QuizAttempt.findOne({
where: {
user_id,
assessment_id: course.assessment.assessment_id,
passed: true,
},
order: [['createdAt', 'DESC']],
attributes: ['score', 'createdAt'],
});
if (!passedAttempt) {
return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
}
// ── 3. Get user's name ─────────────────────────────────────────────────────
const user = await mdl_Users.findByPk(user_id, { attributes: ['personal_info'] }); const user = await mdl_Users.findByPk(user_id, { attributes: ['personal_info'] });
const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant'; const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant';
// ── 4. Resolve or create the certificate record ──────────────────────────── // ── 3. Resolve or create the certificate record ─────────────────────────────
// CockroachDB does not support findOrCreate (uses temp PL/pgSQL functions). // Shared with the hourly issuance cron (cron/jobs/issue_certificates.cron.js)
let cert = await Certificate.findOne({ where: { user_id, course_id: course.course_id } }); // so both write through the same cert_no/ref_no sequence.
const cert = await ensureCertificateRecord({ userId: user_id, courseId: course.course_id });
if (!cert) { if (!cert) {
cert = await Certificate.create({ return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
user_id,
course_id: course.course_id,
cert_no: await buildCertNo(user_id),
ref_no: await buildRefNo(),
instructors: formatInstructors(course.instructors ?? []),
score: passedAttempt.score ?? null,
length_str: formatDuration(course.duration_seconds),
issued_at: passedAttempt.createdAt,
});
} }
// Always use live instructors from course_instructors table for the PDF. // Always use live instructors from course_instructors table for the PDF,
// Keep the snapshot in sync so it reflects the current state. // in case they changed since the certificate row was created.
const liveInstructors = formatInstructors(course.instructors ?? []); const liveInstructors = formatInstructors(course.instructors ?? []);
if (liveInstructors !== (cert.instructors ?? '')) {
await cert.update({ instructors: liveInstructors });
}
// ── 5. Format issued date as MM/DD/YY HH:MM AM/PM ─────────────────────────── // ── 4. Format issued date as MM/DD/YY HH:MM AM/PM ───────────────────────────
const issuedDate = new Date(cert.issued_at); const issuedDate = new Date(cert.issued_at);
const dateStr = fmtDate(issuedDate); const dateStr = fmtDate(issuedDate);
// ── 6. Generate PDF ──────────────────────────────────────────────────────── // ── 5. Generate PDF ────────────────────────────────────────────────────────
const pdf = await generateCertificate({ const pdf = await generateCertificate({
name: fullName, name: fullName,
course: course.title, course: course.title,
@@ -148,7 +90,7 @@ exports.getCertificate = async (req, res) => {
length: cert.length_str ?? '', length: cert.length_str ?? '',
}); });
// ── 7. Stream response ───────────────────────────────────────────────────── // ── 6. Stream response ─────────────────────────────────────────────────────
const nameParts = fullName.trim().split(/\s+/); const nameParts = fullName.trim().split(/\s+/);
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0]; const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0];
const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : ''; const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : '';
+1 -1
View File
@@ -958,7 +958,7 @@ exports.submitCourseAssessment = async (req, res) => {
// Immediate notification: course completed, certificate incoming // Immediate notification: course completed, certificate incoming
UserNotification.create({ UserNotification.create({
user_id, user_id,
...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '' }), ...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null }),
}).catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err)); }).catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err));
} }
+14 -2
View File
@@ -63,11 +63,23 @@ function trackToken(token, ip) {
// ─── Helper: resolve client IP ─────────────────────────────────────────────── // ─── Helper: resolve client IP ───────────────────────────────────────────────
// Collapses IPv4-mapped IPv6 ("::ffff:127.0.0.1") and IPv6 loopback ("::1")
// down to a single canonical form. Without this, a token minted off one
// "localhost" connection (IPv4) fails IP-pin verification on a sibling
// request that happened to land on the other stack (IPv6) — browsers race
// both when resolving "localhost", so mint and stream requests can land on
// different stacks even from the same client.
function normalizeIp(ip) {
if (ip === "::1") return "127.0.0.1";
if (ip.startsWith("::ffff:")) return ip.slice(7);
return ip;
}
function resolveIp(req) { function resolveIp(req) {
// x-forwarded-for is set by reverse proxies (nginx, Caddy, Cloudflare) // x-forwarded-for is set by reverse proxies (nginx, Caddy, Cloudflare)
const forwarded = req.headers["x-forwarded-for"]; const forwarded = req.headers["x-forwarded-for"];
if (forwarded) return forwarded.split(",")[0].trim(); const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown");
return req.ip ?? req.socket?.remoteAddress ?? "unknown"; return normalizeIp(raw);
} }
// ─── Helper: pipe S3 pre-signed URL to response (Range-aware) ──────────────── // ─── Helper: pipe S3 pre-signed URL to response (Range-aware) ────────────────
+15 -1
View File
@@ -6,6 +6,7 @@
* GET /client/notifications/unseen — unseen count * GET /client/notifications/unseen — unseen count
* PATCH /client/notifications/:id/seen — mark one as seen * PATCH /client/notifications/:id/seen — mark one as seen
* PATCH /client/notifications/seen-all — mark all as seen * PATCH /client/notifications/seen-all — mark all as seen
* DELETE /client/notifications/clear-all — delete all notifications
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026 * Date Created: Jun. 19, 2026
@@ -83,4 +84,17 @@ async function markAllSeen(req, res) {
} }
} }
module.exports = { list, unseenCount, markSeen, markAllSeen }; // ─── DELETE /client/notifications/clear-all ──────────────────────────────────
async function clearAll(req, res) {
try {
const count = await UserNotification.destroy({
where: { user_id: req.user.user_id },
});
return R.success(res, `${count} notification(s) cleared.`, { count });
} catch (err) {
console.error('[CLIENT NOTIFICATION] clearAll error:', err);
return R.error(res, 'Failed to clear notifications.');
}
}
module.exports = { list, unseenCount, markSeen, markAllSeen, clearAll };
-17
View File
@@ -66,23 +66,6 @@ exports.updateProfile = async (req, res) => {
} }
}; };
// ─── PATCH preferred currency ──────────────────────────────────────────────────
exports.updateCurrency = async (req, res) => {
try {
const { currency } = req.body;
if (!currency || typeof currency !== 'string' || currency.length !== 3)
return R.error(res, 'A valid 3-letter ISO 4217 currency code is required.', 400);
const user = await mdl_Users.findByPk(req.user.user_id);
await user.update({ preferred_currency: currency.toUpperCase() });
return R.success(res, 'Currency preference updated.', { preferred_currency: user.preferred_currency });
} catch (err) {
console.error('[CLIENT] updateCurrency error:', err);
return R.error(res, 'Could not update currency preference.', 500);
}
};
// ─── GET own sessions ────────────────────────────────────────────────────────── // ─── GET own sessions ──────────────────────────────────────────────────────────
exports.getSessions = async (req, res) => { exports.getSessions = async (req, res) => {
+41 -2
View File
@@ -22,6 +22,7 @@ const { userExclude } = require('../../models/task/task.attributes');
const { clientExclude } = require('../../models/task/task_completion.attributes'); const { clientExclude } = require('../../models/task/task_completion.attributes');
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isUUID = (v) => UUID_RE.test(v); const isUUID = (v) => UUID_RE.test(v);
@@ -45,13 +46,32 @@ exports.getMyGroups = async (req, res) => {
attributes: [], attributes: [],
through: { through: {
model: mdl_UserGroupMembers, model: mdl_UserGroupMembers,
attributes: ['joined_at'], attributes: [],
where: { deletedAt: null }, where: { deletedAt: null },
}, },
}, },
{
model: TaskList,
as: 'taskLists',
attributes: [],
through: { attributes: [] },
required: false,
},
], ],
where: { is_active: true }, where: { is_active: true },
attributes: ['group_id', 'name', 'group_code', 'description'], attributes: [
'group_id',
'name',
'group_code',
'description',
[sequelize.fn('COUNT', sequelize.fn('DISTINCT', sequelize.col('taskLists.task_list_id'))), 'task_list_count'],
],
group: [
'UserGroup.group_id',
'UserGroup.name',
'UserGroup.group_code',
'UserGroup.description',
],
order: [['name', 'ASC']], order: [['name', 'ASC']],
}); });
@@ -175,6 +195,13 @@ exports.getGroupTaskList = async (req, res) => {
const json = taskList.toJSON(); const json = taskList.toJSON();
const tasks = json.tasks ?? []; const tasks = json.tasks ?? [];
const taskIds = tasks.map((t) => t.task_id); const taskIds = tasks.map((t) => t.task_id);
const readRequirements = tasks.flatMap((task) =>
(task.requirements ?? [])
.filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type))
.map((req) => ({ ...req, task_id: task.task_id }))
);
await hydrateReadTaskProgress(userId, readRequirements);
// ── Fetch user's completion signals for these tasks ──────────────────── // ── Fetch user's completion signals for these tasks ────────────────────
const [completions, linkVisits, progressRows] = await Promise.all([ const [completions, linkVisits, progressRows] = await Promise.all([
@@ -316,6 +343,18 @@ exports.getGroupTaskLists = async (req, res) => {
// ── Gather all task_ids across the group's task lists ────────────────── // ── Gather all task_ids across the group's task lists ──────────────────
const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []); const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []);
const taskIds = allTasks.map((t) => t.task_id); const taskIds = allTasks.map((t) => t.task_id);
const readRequirements = allTasks.flatMap((task) =>
(task.requirements ?? [])
.filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type))
.map((req) => ({
task_id: task.task_id,
requirement_id: req.requirement_id,
reference_id: req.reference_id,
type: req.type,
}))
);
await hydrateReadTaskProgress(userId, readRequirements);
// ── Fetch user's completion signals for these tasks ──────────────────── // ── Fetch user's completion signals for these tasks ────────────────────
const [completions, linkVisits, progressRows] = await Promise.all([ const [completions, linkVisits, progressRows] = await Promise.all([
@@ -21,6 +21,7 @@
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026 * Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const { Op } = require('sequelize');
const sequelize = require('../../config/db.config'); const sequelize = require('../../config/db.config');
const { Task, TaskRequirement } = require('../../models/task/task.mdl'); const { Task, TaskRequirement } = require('../../models/task/task.mdl');
@@ -28,6 +29,7 @@ const { TaskLinkVisit, TaskProgress } = require('../../models/task/task
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isUUID = (v) => UUID_RE.test(v); const isUUID = (v) => UUID_RE.test(v);
@@ -101,6 +103,16 @@ exports.getTaskProgress = async (req, res) => {
}); });
if (!task) return R.error(res, 'Task not found.', 404); if (!task) return R.error(res, 'Task not found.', 404);
const readRequirements = await TaskRequirement.findAll({
where: {
task_id: taskId,
type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] },
},
attributes: ['task_id', 'requirement_id', 'reference_id', 'type'],
});
await hydrateReadTaskProgress(req.user.user_id, readRequirements);
const [linkVisits, progress] = await Promise.all([ const [linkVisits, progress] = await Promise.all([
TaskLinkVisit.findAll({ TaskLinkVisit.findAll({
where: { task_id: taskId, user_id: req.user.user_id }, where: { task_id: taskId, user_id: req.user.user_id },
+6 -26
View File
@@ -13,7 +13,6 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl'); const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_PlanPrices = require('../../models/tiers/plan_prices.mdl');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl'); const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
@@ -55,6 +54,7 @@ exports.getMyTier = async (req, res) => {
...NOTIFICATION_REGISTRY.tier_expired.build({ ...NOTIFICATION_REGISTRY.tier_expired.build({
tier: tier.tier, tier: tier.tier,
label: tier.plan?.label ?? null, label: tier.plan?.label ?? null,
planId: tier.plan?.plan_id ?? null,
}), }),
}).catch(() => {}); }).catch(() => {});
return R.success(res, 'Active tier retrieved.', { return R.success(res, 'Active tier retrieved.', {
@@ -114,11 +114,6 @@ exports.getPlans = async (req, res) => {
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'], attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
through: { attributes: [] }, through: { attributes: [] },
}, },
{
model: mdl_PlanPrices,
as: 'prices',
attributes: ['currency', 'price'],
},
], ],
}); });
@@ -139,21 +134,14 @@ exports.getPlans = async (req, res) => {
exports.validatePromo = async (req, res) => { exports.validatePromo = async (req, res) => {
try { try {
const { plan_id, code, currency } = req.body; const { plan_id, code } = req.body;
if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400); if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } }); const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404); if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
// Resolve localized price if a preferred currency was sent
let effectivePrice = null;
if (currency && currency !== plan.currency) {
const priceEntry = await mdl_PlanPrices.findOne({ where: { plan_id, currency } });
if (priceEntry) effectivePrice = priceEntry.price;
}
const policy = await paymentSvc.getPolicyForPlan(plan_id); const policy = await paymentSvc.getPolicyForPlan(plan_id);
const result = await paymentSvc.evaluatePromo(policy, plan, code, effectivePrice); const result = await paymentSvc.evaluatePromo(policy, plan, code, null);
return R.success(res, result.valid ? 'Promo code is valid.' : result.reason, result); return R.success(res, result.valid ? 'Promo code is valid.' : result.reason, result);
} catch (err) { } catch (err) {
@@ -166,22 +154,14 @@ exports.validatePromo = async (req, res) => {
exports.createOrder = async (req, res) => { exports.createOrder = async (req, res) => {
try { try {
const { plan_id, promo_code, currency: requestedCurrency } = req.body; const { plan_id, promo_code } = req.body;
if (!plan_id) return R.error(res, 'plan_id is required.', 400); if (!plan_id) return R.error(res, 'plan_id is required.', 400);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } }); const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404); if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
// Resolve localized price — falls back to plan base price when no override exists const effectivePrice = Number(plan.price);
let effectivePrice = Number(plan.price); const effectiveCurrency = plan.currency;
let effectiveCurrency = plan.currency;
if (requestedCurrency && requestedCurrency !== plan.currency) {
const priceEntry = await mdl_PlanPrices.findOne({ where: { plan_id, currency: requestedCurrency } });
if (priceEntry) {
effectivePrice = Number(priceEntry.price);
effectiveCurrency = priceEntry.currency;
}
}
const policy = await paymentSvc.getPolicyForPlan(plan_id); const policy = await paymentSvc.getPolicyForPlan(plan_id);
+28 -11
View File
@@ -14,8 +14,16 @@
* That's the only wiring required — server.js never needs to * That's the only wiring required — server.js never needs to
* change when admin-side jobs are added/removed. * change when admin-side jobs are added/removed.
* *
* taskOverdue is settings-backed (see cronRegistry.util.js) —
* its schedule/enabled state lives in cron_notification_settings
* and is configurable from /admin/notifications/settings without
* a restart. liftExpiredBans is not notification-related, so it
* stays on a plain hardcoded schedule.
*
* Currently registered: * Currently registered:
* - taskOverdue (cron/jobs/taskOverdue.cron.js) * - taskOverdue (cron/jobs/task_overdue.cron.js) — settings-backed
* - liftExpiredBans (cron/jobs/lift_expired_bans.cron.js)
* - dispatchEmailBroadcasts (cron/jobs/dispatch_email_broadcasts.cron.js)
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 17, 2026 * Date Created: Jun. 17, 2026
@@ -23,24 +31,33 @@
const cron = require('node-cron'); const cron = require('node-cron');
const taskOverdue = require('./jobs/task_overdue.cron'); const taskOverdue = require('./jobs/task_overdue.cron');
const liftExpiredBans = require('./jobs/lift_expired_bans.cron'); const liftExpiredBans = require('./jobs/lift_expired_bans.cron');
const dispatchEmailBroadcasts = require('./jobs/dispatch_email_broadcasts.cron');
const { startSettingsBackedJobs } = require('./cronRegistry.util');
// ─── Registry — add future admin-side cron jobs here ───────────────────────── // ─── Registry — add future admin-side cron jobs here ─────────────────────────
const jobs = [ const settingsBackedJobs = [
taskOverdue, taskOverdue,
];
// Plain hardcoded-schedule jobs (not tied to any notification setting).
const plainJobs = [
liftExpiredBans, liftExpiredBans,
dispatchEmailBroadcasts,
]; ];
// ─── Boot all registered admin-side jobs ────────────────────────────────────── // ─── Boot all registered admin-side jobs ──────────────────────────────────────
function startAdminCronJobs() { async function startAdminCronJobs() {
const registered = []; const registered = await startSettingsBackedJobs(settingsBackedJobs, 'ADMIN');
jobs.forEach(({ name, schedule, run }) => {
if (!cron.validate(schedule)) { for (const job of plainJobs) {
console.error(`[CRON][ADMIN] Invalid schedule for "${name}": "${schedule}" — skipped.`); if (!cron.validate(job.schedule)) {
return; console.error(`[CRON][ADMIN] Invalid schedule for "${job.name}": "${job.schedule}" — skipped.`);
continue;
} }
cron.schedule(schedule, run); cron.schedule(job.schedule, job.run);
registered.push({ name, scope: 'ADMIN', schedule }); registered.push({ name: job.name, scope: 'ADMIN', schedule: job.schedule });
}); }
return registered; return registered;
} }
+8 -12
View File
@@ -5,6 +5,11 @@
* Same shape as admin.cron.js — each job module exports * Same shape as admin.cron.js — each job module exports
* { name, schedule, run }, listed in the `jobs` array below. * { name, schedule, run }, listed in the `jobs` array below.
* *
* All three are settings-backed (see cronRegistry.util.js) —
* schedule/enabled state lives in cron_notification_settings
* and is configurable from /admin/notifications/settings
* without a restart.
*
* Currently registered: * Currently registered:
* - userNotifications (cron/jobs/user_notifications.cron.js) * - userNotifications (cron/jobs/user_notifications.cron.js)
* - issueCertificates (cron/jobs/issue_certificates.cron.js) * - issueCertificates (cron/jobs/issue_certificates.cron.js)
@@ -13,10 +18,10 @@
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 17, 2026 * Date Created: Jun. 17, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const cron = require('node-cron');
const userNotifications = require('./jobs/user_notifications.cron'); const userNotifications = require('./jobs/user_notifications.cron');
const issueCertificates = require('./jobs/issue_certificates.cron'); const issueCertificates = require('./jobs/issue_certificates.cron');
const expireUserTiers = require('./jobs/expire_user_tiers.cron'); const expireUserTiers = require('./jobs/expire_user_tiers.cron');
const { startSettingsBackedJobs } = require('./cronRegistry.util');
// ─── Registry — add future client-side cron jobs here ──────────────────────── // ─── Registry — add future client-side cron jobs here ────────────────────────
const jobs = [ const jobs = [
@@ -26,17 +31,8 @@ const jobs = [
]; ];
// ─── Boot all registered client-side jobs ───────────────────────────────────── // ─── Boot all registered client-side jobs ─────────────────────────────────────
function startClientCronJobs() { async function startClientCronJobs() {
const registered = []; return startSettingsBackedJobs(jobs, 'CLIENT');
jobs.forEach(({ name, schedule, run }) => {
if (!cron.validate(schedule)) {
console.error(`[CRON][CLIENT] Invalid schedule for "${name}": "${schedule}" — skipped.`);
return;
}
cron.schedule(schedule, run);
registered.push({ name, scope: 'CLIENT', schedule });
});
return registered;
} }
module.exports = { startClientCronJobs }; module.exports = { startClientCronJobs };
+82
View File
@@ -0,0 +1,82 @@
/***********************************************************************************************************************************************************************
* File Name : cronRegistry.util.js
* Type : Utility
* Description : Shared machinery for settings-backed cron jobs (the 4 jobs
* that emit notifications and are configurable from
* /admin/notifications/settings). Not every cron job in the
* app goes through this — jobs with no notification tied to
* them (e.g. lift_expired_bans) keep using node-cron directly.
*
* startSettingsBackedJobs() reads each job's schedule from
* cron_notification_settings (falling back to — and seeding —
* the job's own hardcoded default on first boot), then keeps
* a live reference to the scheduled task so it can be swapped
* out later via rescheduleJob() without a server restart.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 2, 2026
***********************************************************************************************************************************************************************/
'use strict';
const cron = require('node-cron');
const CronNotificationSetting = require('../models/notifications/cron_notification_setting.mdl');
// job_name -> { task: ScheduledTask, run: fn }
const runningTasks = new Map();
// CockroachDB can't run Sequelize's findOrCreate() — it wraps the insert in a
// pg_temp PL/pgSQL function to atomically catch unique_violation, which
// CockroachDB rejects ("cannot create user-defined functions under a temporary
// schema"). Plain findOne-then-create sidesteps it; the race window (two boots
// racing to seed the same job_name) is a non-issue here — jobs are seeded once.
async function getOrCreateSetting(jobName, defaultSchedule) {
let row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
if (row) return row;
try {
row = await CronNotificationSetting.create({ job_name: jobName, enabled: true, schedule: defaultSchedule });
} catch (err) {
row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
if (!row) throw err;
}
return row;
}
async function startSettingsBackedJobs(jobs, scopeLabel) {
const registered = [];
for (const { name, schedule: defaultSchedule, run } of jobs) {
let schedule = defaultSchedule;
try {
const settings = await getOrCreateSetting(name, defaultSchedule);
schedule = settings.schedule || defaultSchedule;
} catch (err) {
console.error(`[CRON][${scopeLabel}] Failed to load settings for "${name}", using hardcoded default:`, err);
}
if (!cron.validate(schedule)) {
console.error(`[CRON][${scopeLabel}] Invalid schedule for "${name}": "${schedule}" — skipped.`);
continue;
}
const task = cron.schedule(schedule, run);
runningTasks.set(name, { task, run });
registered.push({ name, scope: scopeLabel, schedule });
}
return registered;
}
// Live-swap a running job's schedule — used by notificationSettings.controller.js
// after an admin picks a new preset. No server restart required.
function rescheduleJob(jobName, newSchedule) {
const entry = runningTasks.get(jobName);
if (!entry) throw new Error(`No running cron task found for "${jobName}".`);
if (!cron.validate(newSchedule)) throw new Error(`Invalid cron schedule: "${newSchedule}".`);
entry.task.stop();
const task = cron.schedule(newSchedule, entry.run);
runningTasks.set(jobName, { task, run: entry.run });
}
module.exports = { startSettingsBackedJobs, rescheduleJob, getOrCreateSetting };
+110
View File
@@ -0,0 +1,110 @@
/***********************************************************************************************************************************************************************
* File Name : dispatch_email_broadcasts.cron.js
* Type : Cron Job
* Description : Sends real, paced SMTP email for queued email_broadcasts —
* the actual delivery half of controllers/admin/email_broadcasts
* .controller.js's createEmailBroadcast(), which only ever
* enqueues rows and returns immediately.
*
* Every tick, picks up to BATCH_SIZE 'pending' recipient rows
* (oldest broadcast first, FIFO within it) and sends them one
* at a time with a short delay between each — this is the "one
* by one, not a blocking for-loop in the API request" behavior:
* it's fine to block *here* because nothing is waiting on an
* HTTP response, and the delay keeps us well under Gmail SMTP's
* practical sustained-send pacing.
*
* Resumable by construction — if the process restarts mid-
* broadcast, the next tick just keeps consuming whatever rows
* are still 'pending'. Single-instance only: this does not use
* row-level locking, so running more than one app instance
* would let two ticks grab the same batch. Fine for the
* current single-process deployment; would need SELECT ... FOR
* UPDATE SKIP LOCKED before scaling horizontally.
*
* Schedule : Every minute ("* * * * *"). Registered by cron/admin.cron.js.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 3, 2026
***********************************************************************************************************************************************************************/
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 { sendEmail } = require('../../services/email.service');
const BATCH_SIZE = 25;
const DELAY_MS = 600; // pacing between individual sends — keeps us well under Gmail's throttling threshold
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function run() {
try {
const recipients = await mdl_EmailBroadcastRecipient.findAll({
where: { status: 'pending' },
include: [{
model: mdl_EmailBroadcast,
as: 'broadcast',
where: { status: ['queued', 'sending'] },
include: [{ model: mdl_EmailTemplate, as: 'template' }],
}],
order: [['email_broadcast_id', 'ASC'], ['email_broadcast_recipient_id', 'ASC']],
limit: BATCH_SIZE,
});
if (!recipients.length) return;
const touchedBroadcastIds = new Set();
for (const recipient of recipients) {
const broadcast = recipient.broadcast;
const template = broadcast?.template;
if (!broadcast || !template) {
await recipient.update({ status: 'failed', error: 'Broadcast or template no longer exists.', sent_at: new Date() });
continue;
}
if (broadcast.status === 'queued') {
await broadcast.update({ status: 'sending', started_at: broadcast.started_at ?? new Date() });
}
touchedBroadcastIds.add(broadcast.email_broadcast_id);
try {
await sendEmail({
to: recipient.email,
type: template.type,
data: { name: recipient.name || 'there', email: recipient.email },
});
await recipient.update({ status: 'sent', sent_at: new Date() });
await broadcast.increment('sent_count');
} catch (err) {
await recipient.update({ status: 'failed', error: err.message, sent_at: new Date() });
await broadcast.increment('failed_count');
console.error('[CRON][EMAIL BROADCAST] Send failed:', recipient.email, err.message);
}
await delay(DELAY_MS);
}
// Close out any broadcast that has no pending recipients left.
for (const broadcastId of touchedBroadcastIds) {
const remaining = await mdl_EmailBroadcastRecipient.count({ where: { email_broadcast_id: broadcastId, status: 'pending' } });
if (remaining === 0) {
await mdl_EmailBroadcast.update(
{ status: 'completed', completed_at: new Date() },
{ where: { email_broadcast_id: broadcastId, status: ['queued', 'sending'] } }
);
}
}
console.log(`[CRON][EMAIL BROADCAST] Processed ${recipients.length} recipient(s) across ${touchedBroadcastIds.size} broadcast(s).`);
} catch (err) {
console.error('[CRON][EMAIL BROADCAST] Failed:', err);
}
}
module.exports = {
name: 'dispatchEmailBroadcasts',
schedule: '* * * * *',
run,
};
+7 -1
View File
@@ -27,6 +27,7 @@ const { Op } = require('sequelize');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
require('../../models/tiers/tier.associations'); require('../../models/tiers/tier.associations');
@@ -43,7 +44,7 @@ async function run() {
include: [{ include: [{
model: mdl_TierPlans, model: mdl_TierPlans,
as: 'plan', as: 'plan',
attributes: ['label', 'tier'], attributes: ['plan_id', 'label', 'tier'],
required: false, required: false,
}], }],
attributes: ['tier_id', 'user_id', 'tier'], attributes: ['tier_id', 'user_id', 'tier'],
@@ -69,10 +70,14 @@ async function run() {
} }
// ── 3. Send in-app notifications (one per affected user) ────────────────── // ── 3. Send in-app notifications (one per affected user) ──────────────────
// Status flip above always happens — only this step is skippable via settings.
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'expireUserTiers' } });
if (!settings || settings.enabled) {
const notifications = expired.map((t) => const notifications = expired.map((t) =>
NOTIFICATION_REGISTRY.tier_expired.build({ NOTIFICATION_REGISTRY.tier_expired.build({
tier: t.tier, tier: t.tier,
label: t.plan?.label ?? null, label: t.plan?.label ?? null,
planId: t.plan?.plan_id ?? null,
}) })
).map((payload, i) => ({ ).map((payload, i) => ({
user_id: expired[i].user_id, user_id: expired[i].user_id,
@@ -84,6 +89,7 @@ async function run() {
} catch (err) { } catch (err) {
console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err); console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err);
} }
}
console.log(`[CRON][EXPIRE TIERS] Expired ${expired.length} tier(s) for ${new Set(expired.map((t) => t.user_id)).size} user(s).`); console.log(`[CRON][EXPIRE TIERS] Expired ${expired.length} tier(s) for ${new Set(expired.map((t) => t.user_id)).size} user(s).`);
} }
+20 -4
View File
@@ -9,8 +9,11 @@
* For each ready row it: * For each ready row it:
* 1. Grants the course_completed_<uuid> achievement (the key * 1. Grants the course_completed_<uuid> achievement (the key
* MyCertificates / Profile use to display certificate cards). * MyCertificates / Profile use to display certificate cards).
* 2. Sends a 'certificate_issued' UserNotification. * 2. Persists the certificate record (cert_no/ref_no) via
* 3. Marks the row processed_at = NOW() so it never fires again. * services/certificate-record.service.js, so course.certificate
* is populated immediately instead of only on first PDF download.
* 3. Sends a 'certificate_issued' UserNotification.
* 4. Marks the row processed_at = NOW() so it never fires again.
* *
* Safety pattern: processed_at is set only after both step 1 and * Safety pattern: processed_at is set only after both step 1 and
* step 2 succeed. If the process restarts mid-run the row will be * step 2 succeed. If the process restarts mid-run the row will be
@@ -28,9 +31,15 @@ const { Op } = require('sequelize');
const PendingCertificate = require('../../models/courses/pending_certificate.mdl'); const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
const mdl_Achievements = require('../../models/users/achievements.mdl'); const mdl_Achievements = require('../../models/users/achievements.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { ensureCertificateRecord } = require('../../services/certificate-record.service');
async function run() { async function run() {
// Certificate/achievement issuance always happens — only the notification step is skippable.
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'issueCertificates' } });
const notificationsEnabled = !settings || settings.enabled;
// ── 1. Fetch all rows ready to process ──────────────────────────────────── // ── 1. Fetch all rows ready to process ────────────────────────────────────
let rows; let rows;
try { try {
@@ -69,7 +78,13 @@ async function run() {
}); });
} }
// ── 3. Send certificate_issued notification ──────────────────────── // ── 3. Persist the actual certificate record (cert_no/ref_no) so
// course.certificate is populated immediately, instead of only
// lazily on first PDF download ────────────────────────────────
await ensureCertificateRecord({ userId: user_id, courseId: row.course_id });
// ── 4. Send certificate_issued notification ─────────────────────────
if (notificationsEnabled) {
await UserNotification.create({ await UserNotification.create({
user_id, user_id,
...NOTIFICATION_REGISTRY.certificate_issued.build({ ...NOTIFICATION_REGISTRY.certificate_issued.build({
@@ -77,8 +92,9 @@ async function run() {
courseUuid: course_uuid, courseUuid: course_uuid,
}), }),
}); });
}
// ── 4. Mark row processed ───────────────────────────────────────── // ── 5. Mark row processed ─────────────────────────────────────────
await PendingCertificate.update( await PendingCertificate.update(
{ processed_at: new Date() }, { processed_at: new Date() },
{ where: { pending_id } } { where: { pending_id } }
+5
View File
@@ -25,6 +25,7 @@
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const { Task } = require('../../models/task/task.mdl'); const { Task } = require('../../models/task/task.mdl');
const AdminNotification = require('../../models/notifications/admin_notification.mdl'); const AdminNotification = require('../../models/notifications/admin_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
// ─── The actual sweep ──────────────────────────────────────────────────────── // ─── The actual sweep ────────────────────────────────────────────────────────
@@ -52,7 +53,11 @@ async function run() {
console.log(`[CRON][TASK OVERDUE] Marked ${affectedCount} task(s) as overdue.`); console.log(`[CRON][TASK OVERDUE] Marked ${affectedCount} task(s) as overdue.`);
// ── 2. Secondary: admin notification — isolated, never blocks step 1 ───── // ── 2. Secondary: admin notification — isolated, never blocks step 1 ─────
// Skippable via /admin/notifications/settings — the status flip above always happens either way.
try { try {
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskOverdue' } });
if (settings && !settings.enabled) return;
await AdminNotification.create( await AdminNotification.create(
NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount }) NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount })
); );
+5
View File
@@ -24,11 +24,16 @@ const { Op, QueryTypes } = require('sequelize');
const sequelize = require('../../config/db.config'); const sequelize = require('../../config/db.config');
const { Task } = require('../../models/task/task.mdl'); const { Task } = require('../../models/task/task.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback const WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback
async function run() { async function run() {
// Entire job exists to emit this notification — skippable via /admin/notifications/settings.
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'userNotifications' } });
if (settings && !settings.enabled) return;
// ── 1. Find tasks that flipped to overdue in the last 65 minutes ────────── // ── 1. Find tasks that flipped to overdue in the last 65 minutes ──────────
let recentlyOverdue; let recentlyOverdue;
try { try {
+5 -105
View File
@@ -1,119 +1,19 @@
/*********************************************************************************************************************************************************************** /***********************************************************************************************************************************************************************
* File Name: achievements.data.js * File Name: achievements.data.js
* Type of Program: Data * Type of Program: Data
* Description: Static registry of all available achievements (badges and milestones). * Description: Static config for the achievement system that isn't part of the
* Add a new achievement here — no other code changes needed. * admin-managed catalog. The achievement catalog itself (keys, type,
* * label, description, icon) now lives in the achievement_definitions
* Available keys: * table — see models/users/achievement_definitions.mdl.js and
* Badge: * controllers/admin/achievements.controller.js for CRUD.
* early_access — registered before Dec 31, 2026
* premium_first_time — first premium tier purchase
* exclusive_first_time — first exclusive tier purchase
* Milestone:
* first_course_completed — completed first course
* courses_completed_5 — completed 5 courses
* courses_completed_10 — completed 10 courses
* perfect_quiz_score — perfect score on a quiz
* profile_completed — filled out full profile
* first_referral — referred a user
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026 * Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
'use strict'; 'use strict';
// ─── Early Access cutoff ──────────────────────────────────────────────────────
const EARLY_ACCESS_CUTOFF = new Date('2026-12-31T23:59:59Z'); const EARLY_ACCESS_CUTOFF = new Date('2026-12-31T23:59:59Z');
// ─── Achievement Registry ─────────────────────────────────────────────────────
// To add a new achievement — add an entry here. No other code changes needed.
//
// trigger categories (for documentation only, not enforced):
// auth — registration / login events
// tier — subscription purchase events
// course — course / lesson / quiz events
// profile — profile completion events
// social — referral / community events
const ACHIEVEMENT_REGISTRY = {
// ── Auth ───────────────────────────────────────────────────────────────────
early_access: {
key: 'early_access',
type: 'badge',
label: 'Early Access',
description: 'Registered during the Philproperties beta period (before Dec 31, 2026).',
trigger: 'auth',
},
// ── Tier ───────────────────────────────────────────────────────────────────
premium_first_time: {
key: 'premium_first_time',
type: 'badge',
label: 'Premium Member',
description: 'Purchased a Premium tier plan for the first time.',
trigger: 'tier',
},
exclusive_first_time: {
key: 'exclusive_first_time',
type: 'badge',
label: 'Exclusive Member',
description: 'Purchased an Exclusive tier plan for the first time.',
trigger: 'tier',
},
// ── Course ─────────────────────────────────────────────────────────────────
first_course_completed: {
key: 'first_course_completed',
type: 'milestone',
label: 'First Course Completed',
description: 'Completed your very first course on Philproperties.',
trigger: 'course',
},
courses_completed_5: {
key: 'courses_completed_5',
type: 'milestone',
label: 'Learning Streak',
description: 'Completed 5 courses.',
trigger: 'course',
},
courses_completed_10: {
key: 'courses_completed_10',
type: 'milestone',
label: 'Knowledge Builder',
description: 'Completed 10 courses.',
trigger: 'course',
},
perfect_quiz_score: {
key: 'perfect_quiz_score',
type: 'milestone',
label: 'Perfect Score',
description: 'Achieved a perfect score on a quiz.',
trigger: 'course',
},
// ── Profile ────────────────────────────────────────────────────────────────
profile_completed: {
key: 'profile_completed',
type: 'milestone',
label: 'Profile Complete',
description: 'Filled out all personal profile information.',
trigger: 'profile',
},
// ── Social ─────────────────────────────────────────────────────────────────
first_referral: {
key: 'first_referral',
type: 'milestone',
label: 'Referral Champion',
description: 'Successfully referred a user to Philproperties.',
trigger: 'social',
},
};
module.exports = { module.exports = {
EARLY_ACCESS_CUTOFF, EARLY_ACCESS_CUTOFF,
ACHIEVEMENT_REGISTRY,
}; };
+28
View File
@@ -0,0 +1,28 @@
/***********************************************************************************************************************************************************************
* File Name: cronPresets.data.js
* Type of Program: Data
* Description: Friendly schedule presets for admin-configurable notification
* crons. The UI only ever offers these six options — no raw cron
* expressions are accepted from the client, so `updateSetting`
* in notificationSettings.controller.js validates against this map.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 2, 2026
***********************************************************************************************************************************************************************/
'use strict';
const CRON_PRESETS = {
every_minute: '* * * * *',
every_5_min: '*/5 * * * *',
every_15_min: '*/15 * * * *',
hourly: '0 * * * *',
every_6_hours: '0 */6 * * *',
daily: '0 0 * * *',
};
// Reverse lookup — cron string -> preset key (used to label a job's current schedule)
const CRON_PRESET_BY_EXPRESSION = Object.fromEntries(
Object.entries(CRON_PRESETS).map(([key, expr]) => [expr, key])
);
module.exports = { CRON_PRESETS, CRON_PRESET_BY_EXPRESSION };
-105
View File
@@ -1,105 +0,0 @@
const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;';
const wrap = (body) => `
<html>
<body style="${FONT} line-height: 1.6;">
${body.trim()}
<br><br>
<p style="margin: 0;">Regards,<br>Philproperties IT Team</p>
<p style="margin: 0; font-size: 12px; color: #555;">This is an automated message from STARR System. Please do not reply.</p>
</body>
</html>`.trim();
export const emailTemplates = {
OTP: ({ otp, expiryMinutes = 10 }) => ({
subject: "Email OTP Verification - STARR System",
html: wrap(`
<p>Dear User,</p>
<p>Please use the One-Time Password (OTP) below to verify your email address. This code is valid for <strong>${expiryMinutes} minutes</strong>.</p>
<p style="font-size: 28px; font-weight: bold; letter-spacing: 6px;">${otp}</p>
<p>For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.</p>
`),
}),
WELCOME: ({ name }) => ({
subject: "Welcome to STARR System",
html: wrap(`
<p>Dear ${name},</p>
<p>We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.</p>
<p>You may now access your dashboard and begin using the available services.</p>
<p>We look forward to supporting you.</p>
`),
}),
PASSWORD_CHANGED: () => ({
subject: "Password Update Confirmation - STARR System",
html: wrap(`
<p>Dear User,</p>
<p>This is to confirm that your account password has been successfully changed.</p>
<p>If you did not perform this action, please reset your password immediately or contact support.</p>
<p>For your security, we recommend using a strong and unique password.</p>
`),
}),
ADDED_TO_GROUP: ({ groupName }) => ({
subject: "Group Assignment Notification - STARR System",
html: wrap(`
<p>Dear User,</p>
<p>You have been assigned to the group <strong>${groupName}</strong> in the STARR System.</p>
<p>This assignment grants you access to shared resources and collaboration tools within the group.</p>
<p>Please log in to your account to view group details.</p>
`),
}),
TASK_ASSIGNED: ({ taskTitle, dueDate }) => ({
subject: "New Task Assignment - STARR System",
html: wrap(`
<p>Dear User,</p>
<p>You have been assigned a new task in the STARR System.</p>
<table style="${FONT}">
<tr><td><strong>Task</strong></td><td>${taskTitle}</td></tr>
<tr><td><strong>Due Date</strong></td><td>${dueDate}</td></tr>
</table>
<p>Kindly ensure completion within the specified timeframe.</p>
`),
}),
BAN_LIFTED: ({ name, email, date }) => ({
subject: "Account Suspension Lifted - STARR System",
html: wrap(`
<p>Dear ${name},</p>
<p>We are writing to inform you that the suspension on your account (<strong>${email}</strong>) has been lifted effective <strong>${date}</strong>.</p>
<p>You may now log in and resume access to all services within the STARR System.</p>
<p>If you have any concerns, please do not hesitate to contact your administrator.</p>
`),
}),
BANNED: ({ name, email, date, reason, ban_type }) => ({
subject: "Account Suspension Notice - STARR System",
html: wrap(`
<p>Dear ${name},</p>
<p>Your account (<strong>${email}</strong>) has been ${ban_type === 'permanent' ? 'permanently' : 'temporarily'} suspended from the STARR System effective <strong>${date}</strong>.</p>
<table style="${FONT}">
<tr><td><strong>Reason</strong></td><td>${reason}</td></tr>
<tr><td><strong>Duration</strong></td><td>${ban_type === 'permanent' ? 'Permanent' : 'Temporary'}</td></tr>
</table>
<p>During this period, access to all system services has been revoked.${ban_type === 'permanent' ? '' : ' This suspension may be lifted upon review by the administrator.'}</p>
<p>If you believe this was made in error, please contact your administrator.</p>
`),
}),
ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({
subject: "Your Staff Account Has Been Created - STARR System",
html: wrap(`
<p>Dear ${name},</p>
<p>Your staff account has been successfully created in the STARR System. Below are your login credentials:</p>
<table style="${FONT}">
<tr><td><strong>Email</strong></td><td>${email}</td></tr>
<tr><td><strong>Password</strong></td><td>${password}</td></tr>
</table>
<p>This temporary password is valid for <strong>${expiryHours} hours</strong>. You will be required to change it upon first login.</p>
<p>If you did not request this account or believe this was created in error, please contact your administrator immediately to have it deactivated.</p>
<p>For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.</p>
`),
}),
};
+28
View File
@@ -0,0 +1,28 @@
/***********************************************************************************************************************************************************************
* File Name: email_template_enrichers.data.js
* Type of Program: Data / Registry
* Description: Admin-edited templates are plain HTML — no conditionals or
* expressions allowed. Any type that used to branch on data in
* JS (e.g. BANNED's permanent/temporary wording) gets that branch
* precomputed here into flat placeholder keys BEFORE substitution,
* so the stored HTML only ever needs straight {{key}} swaps.
* Adding a new derived placeholder for a type should only require
* adding/editing one entry here.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 3, 2026
***********************************************************************************************************************************************************************/
const ENRICHERS = {
OTP: (data) => ({ expiryMinutes: 10, ...data }),
BANNED: (data) => ({
...data,
duration_word: data.ban_type === 'permanent' ? 'permanently' : 'temporarily',
duration_label: data.ban_type === 'permanent' ? 'Permanent' : 'Temporary',
suspension_note: data.ban_type === 'permanent' ? '' : ' This suspension may be lifted upon review by the administrator.',
}),
};
const enrichEmailData = (type, data = {}) => (ENRICHERS[type] ? ENRICHERS[type](data) : data);
module.exports = { enrichEmailData };
+43 -13
View File
@@ -20,9 +20,10 @@
* *
* Current types: * Current types:
* Admin : task_overdue, user_registration, nogrp_user_registered * Admin : task_overdue, user_registration, nogrp_user_registered
* User : user_task_overdue, achievement, course_unlocked, * User : task_requirements_updated, user_task_overdue, achievement, course_unlocked,
* course_completed, certificate_issued, task_reminder, announcement, * course_completed, certificate_issued, task_reminder, announcement,
* nogrp_welcome, tier_expired * nogrp_welcome, tier_expired
* Both : broadcast (admin-composed, sent via notification_broadcasts CRUD)
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026 * Date Created: Jun. 19, 2026
@@ -87,6 +88,20 @@ const NOTIFICATION_REGISTRY = {
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// ── Task ────────────────────────────────────────────────────────────────── // ── Task ──────────────────────────────────────────────────────────────────
task_requirements_updated: {
type: 'task',
scope: 'user',
trigger: 'event',
build({ taskName, taskListId = null, groupId = null }) {
return {
type: 'task',
title: 'Task Updated',
message: `The requirements for "${taskName}" have been updated by your administrator.`,
data: { taskName, taskListId, groupId },
};
},
},
user_task_overdue: { user_task_overdue: {
type: 'task', type: 'task',
scope: 'user', scope: 'user',
@@ -106,12 +121,12 @@ const NOTIFICATION_REGISTRY = {
type: 'task', type: 'task',
scope: 'user', scope: 'user',
trigger: 'cron', trigger: 'cron',
build({ taskName, deadline }) { build({ taskName, deadline, taskListId = null, groupId = null }) {
return { return {
type: 'task', type: 'task',
title: 'Task Deadline Approaching', title: 'Task Deadline Approaching',
message: `"${taskName}" is due on ${fmtDate(deadline)}.`, message: `"${taskName}" is due on ${fmtDate(deadline)}.`,
data: { taskName, deadline }, data: { taskName, deadline, taskListId, groupId },
}; };
}, },
}, },
@@ -136,12 +151,12 @@ const NOTIFICATION_REGISTRY = {
type: 'course', type: 'course',
scope: 'user', scope: 'user',
trigger: 'event', trigger: 'event',
build({ courseTitle }) { build({ courseTitle, courseUuid = null }) {
return { return {
type: 'course', type: 'course',
title: 'New Course Available', title: 'New Course Available',
message: `"${courseTitle}" has been added to your learning library.`, message: `"${courseTitle}" has been added to your learning library.`,
data: { courseTitle }, data: { courseTitle, courseUuid },
}; };
}, },
}, },
@@ -150,12 +165,12 @@ const NOTIFICATION_REGISTRY = {
type: 'course', type: 'course',
scope: 'user', scope: 'user',
trigger: 'event', trigger: 'event',
build({ courseTitle }) { build({ courseTitle, courseUuid = null }) {
return { return {
type: 'course', type: 'course',
title: 'Course Completed', title: 'Course Completed',
message: `Great job! You've completed "${courseTitle}". Your certificate will be issued within the next hour.`, message: `Great job! You've completed "${courseTitle}". Your certificate will be issued within the next hour.`,
data: { courseTitle }, data: { courseTitle, courseUuid },
}; };
}, },
}, },
@@ -179,7 +194,7 @@ const NOTIFICATION_REGISTRY = {
type: 'announcement', type: 'announcement',
scope: 'user', scope: 'user',
trigger: 'event', trigger: 'event',
build({ groupName, groupCode, accType }) { build({ groupName, groupCode, accType, groupId = null }) {
const greeting = accType === 'admin' const greeting = accType === 'admin'
? 'Welcome, Administrator!' ? 'Welcome, Administrator!'
: accType === 'staff' : accType === 'staff'
@@ -189,7 +204,7 @@ const NOTIFICATION_REGISTRY = {
type: 'announcement', type: 'announcement',
title: 'Welcome to Philproperties', title: 'Welcome to Philproperties',
message: groupName ? `${greeting} You have been added to ${groupName}.` : greeting, message: groupName ? `${greeting} You have been added to ${groupName}.` : greeting,
data: { groupName, groupCode, accType }, data: { groupName, groupCode, accType, groupId },
}; };
}, },
}, },
@@ -214,12 +229,12 @@ const NOTIFICATION_REGISTRY = {
type: 'assessment', type: 'assessment',
scope: 'user', scope: 'user',
trigger: 'event', trigger: 'event',
build({ assessmentTitle, courseTitle }) { build({ assessmentTitle, courseTitle, courseUuid = null }) {
return { return {
type: 'assessment', type: 'assessment',
title: 'Assessment Updated', title: 'Assessment Updated',
message: `The administrator has updated the "${assessmentTitle || 'Course Assessment'}" in "${courseTitle || 'your course'}". Your current session is still valid — continue where you left off.`, message: `The administrator has updated the "${assessmentTitle || 'Course Assessment'}" in "${courseTitle || 'your course'}". Your current session is still valid — continue where you left off.`,
data: { assessmentTitle, courseTitle }, data: { assessmentTitle, courseTitle, courseUuid },
}; };
}, },
}, },
@@ -239,17 +254,32 @@ const NOTIFICATION_REGISTRY = {
}, },
}, },
// ── Broadcast (admin-composed, manual) ───────────────────────────────────
broadcast: {
type: 'announcement',
scope: 'both',
trigger: 'manual',
build({ title, message, targetType = null, targetId = null, groupId = null }) {
return {
type: 'announcement',
title,
message,
data: { targetType, targetId, groupId },
};
},
},
// ── Tier ────────────────────────────────────────────────────────────────── // ── Tier ──────────────────────────────────────────────────────────────────
tier_expired: { tier_expired: {
type: 'tier_expired', type: 'tier_expired',
scope: 'user', scope: 'user',
trigger: 'cron', trigger: 'cron',
build({ tier, label }) { build({ tier, label, planId = null }) {
return { return {
type: 'tier_expired', type: 'tier_expired',
title: 'Subscription Expired', title: 'Subscription Expired',
message: `Your ${label ?? tier} plan has expired. Renew to keep access.`, message: `Your ${label ?? tier} plan has expired. Renew to keep access.`,
data: { tier, label }, data: { tier, label, planId },
}; };
}, },
}, },
@@ -1,15 +0,0 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('users', 'preferred_currency', {
type: Sequelize.CHAR(3),
allowNull: false,
defaultValue: 'USD',
});
},
async down(queryInterface) {
await queryInterface.removeColumn('users', 'preferred_currency');
},
};
@@ -1,34 +0,0 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('plan_prices', {
price_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: { model: 'tier_plans', key: 'plan_id' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
currency: { type: Sequelize.CHAR(3), allowNull: false },
price: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addConstraint('plan_prices', {
fields: ['plan_id', 'currency'],
type: 'unique',
name: 'uq_plan_prices_plan_currency',
});
await queryInterface.addIndex('plan_prices', ['plan_id'], {
name: 'idx_plan_prices_plan_id',
});
},
async down(queryInterface) {
await queryInterface.dropTable('plan_prices');
},
};
@@ -0,0 +1,31 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('notification_broadcasts', {
broadcast_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
title: { type: Sequelize.STRING(255), allowNull: false },
message: { type: Sequelize.TEXT, allowNull: false },
audience: { type: Sequelize.ENUM('admin', 'user', 'both'), allowNull: false },
status: { type: Sequelize.ENUM('draft', 'sent', 'archived'), allowNull: false, defaultValue: 'draft' },
sent_at: { type: Sequelize.DATE, allowNull: true },
recipient_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('notification_broadcasts', ['uuid']);
await queryInterface.addIndex('notification_broadcasts', ['status']);
await queryInterface.addIndex('notification_broadcasts', ['audience']);
await queryInterface.addIndex('notification_broadcasts', ['deletedAt']);
},
async down(queryInterface) {
await queryInterface.dropTable('notification_broadcasts');
},
};
@@ -0,0 +1,27 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// CockroachDB: add enum values in place, then rename the column.
// Type is named 'notification_broadcast_audience' (matches the explicit CREATE TYPE
// used when this table was first created — not Sequelize's default enum_<table>_<col> name).
await queryInterface.sequelize.query(`ALTER TYPE public.notification_broadcast_audience ADD VALUE IF NOT EXISTS 'task_list'`);
await queryInterface.sequelize.query(`ALTER TYPE public.notification_broadcast_audience ADD VALUE IF NOT EXISTS 'course'`);
await queryInterface.sequelize.query(`ALTER TYPE public.notification_broadcast_audience ADD VALUE IF NOT EXISTS 'tier_plan'`);
await queryInterface.renameColumn('notification_broadcasts', 'audience', 'target_type');
await queryInterface.addColumn('notification_broadcasts', 'target_id', {
type: Sequelize.STRING(64),
allowNull: true,
});
await queryInterface.removeIndex('notification_broadcasts', ['audience']).catch(() => {});
await queryInterface.addIndex('notification_broadcasts', ['target_type']);
},
async down(queryInterface) {
await queryInterface.removeColumn('notification_broadcasts', 'target_id');
await queryInterface.renameColumn('notification_broadcasts', 'target_type', 'audience');
},
};
@@ -0,0 +1,18 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('cron_notification_settings', {
job_name: { type: Sequelize.STRING(64), primaryKey: true },
enabled: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
schedule: { type: Sequelize.STRING(20), allowNull: false },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
},
async down(queryInterface) {
await queryInterface.dropTable('cron_notification_settings');
},
};
@@ -0,0 +1,28 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('advertisements', 'placement', {
type: Sequelize.STRING(100),
allowNull: true,
});
await queryInterface.addIndex('advertisements', ['placement']);
// Backfill the only two placements that were actually wired up before this
// migration (Dashboard hero + popup). Any existing banner/sidebar rows are
// left with placement = NULL — dev data, an admin reassigns them via the
// edit form once the new Page/Position picker ships.
await queryInterface.sequelize.query(`
UPDATE advertisements SET placement = 'dashboard.hero' WHERE type = 'hero' AND placement IS NULL
`);
await queryInterface.sequelize.query(`
UPDATE advertisements SET placement = 'dashboard.popup' WHERE type = 'popup' AND placement IS NULL
`);
},
async down(queryInterface) {
await queryInterface.removeIndex('advertisements', ['placement']).catch(() => {});
await queryInterface.removeColumn('advertisements', 'placement');
},
};
@@ -0,0 +1,40 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('achievement_definitions', {
achievement_definition_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
key: { type: Sequelize.STRING(100), allowNull: false, unique: true },
type: { type: Sequelize.ENUM('badge', 'milestone'), allowNull: false, defaultValue: 'badge' },
label: { type: Sequelize.STRING(255), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
icon: { type: Sequelize.STRING(50), allowNull: true },
trigger: { type: Sequelize.STRING(30), allowNull: true },
is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
is_system: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
// Seed the 9 built-in keys that services/achievements.service.js's trigger
// functions reference by name — these are marked is_system so they can't be
// deleted/renamed from the admin CRUD.
await queryInterface.sequelize.query(`
INSERT INTO achievement_definitions (key, type, label, description, icon, trigger, is_active, is_system, "createdAt", "updatedAt")
VALUES
('early_access', 'badge', 'Early Access', 'Registered during the Philproperties beta period (before Dec 31, 2026).', 'Star', 'auth', true, true, NOW(), NOW()),
('premium_first_time', 'badge', 'Premium Member', 'Purchased a Premium tier plan for the first time.', 'BadgeCheck', 'tier', true, true, NOW(), NOW()),
('exclusive_first_time', 'badge', 'Exclusive Member', 'Purchased an Exclusive tier plan for the first time.', 'Medal', 'tier', true, true, NOW(), NOW()),
('first_course_completed', 'milestone', 'First Course Completed', 'Completed your very first course on Philproperties.', 'BookOpen', 'course', true, true, NOW(), NOW()),
('courses_completed_5', 'milestone', 'Learning Streak', 'Completed 5 courses.', 'Flame', 'course', true, true, NOW(), NOW()),
('courses_completed_10', 'milestone', 'Knowledge Builder', 'Completed 10 courses.', 'Zap', 'course', true, true, NOW(), NOW()),
('perfect_quiz_score', 'milestone', 'Perfect Score', 'Achieved a perfect score on a quiz.', 'Target', 'course', true, true, NOW(), NOW()),
('profile_completed', 'milestone', 'Profile Complete', 'Filled out all personal profile information.', 'Shield', 'profile', true, true, NOW(), NOW()),
('first_referral', 'milestone', 'Referral Champion', 'Successfully referred a user to Philproperties.', 'Award', 'social', true, true, NOW(), NOW())
`);
},
async down(queryInterface) {
await queryInterface.dropTable('achievement_definitions');
},
};
@@ -0,0 +1,14 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('achievements', 'icon', {
type: Sequelize.STRING(50),
allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('achievements', 'icon');
},
};
@@ -0,0 +1,114 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('email_templates', {
email_template_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
type: { type: Sequelize.STRING(100), allowNull: false, unique: true },
label: { type: Sequelize.STRING(150), allowNull: false },
subject: { type: Sequelize.STRING(255), allowNull: false },
html_body: { type: Sequelize.TEXT, allowNull: false },
is_system: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;';
const now = new Date();
// Seed the 8 built-in types that services/email.service.js's sendEmail()
// looks up by name — these are marked is_system so they can't be
// deleted/renamed from the admin CRUD. Body content only (no <html>/<body>
// wrapper) — the header/footer layout stays fixed in code, not admin-editable.
await queryInterface.bulkInsert('email_templates', [
{
type: 'OTP', label: 'OTP Verification', is_system: true,
subject: 'Email OTP Verification - STARR System',
html_body: `<p>Dear User,</p>
<p>Please use the One-Time Password (OTP) below to verify your email address. This code is valid for <strong>{{expiryMinutes}} minutes</strong>.</p>
<p style="font-size: 28px; font-weight: bold; letter-spacing: 6px;">{{otp}}</p>
<p>For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.</p>`,
createdAt: now, updatedAt: now,
},
{
type: 'WELCOME', label: 'Welcome Email', is_system: true,
subject: 'Welcome to STARR System',
html_body: `<p>Dear {{name}},</p>
<p>We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.</p>
<p>You may now access your dashboard and begin using the available services.</p>
<p>We look forward to supporting you.</p>`,
createdAt: now, updatedAt: now,
},
{
type: 'PASSWORD_CHANGED', label: 'Password Changed', is_system: true,
subject: 'Password Update Confirmation - STARR System',
html_body: `<p>Dear User,</p>
<p>This is to confirm that your account password has been successfully changed.</p>
<p>If you did not perform this action, please reset your password immediately or contact support.</p>
<p>For your security, we recommend using a strong and unique password.</p>`,
createdAt: now, updatedAt: now,
},
{
type: 'ADDED_TO_GROUP', label: 'Added to Group', is_system: true,
subject: 'Group Assignment Notification - STARR System',
html_body: `<p>Dear User,</p>
<p>You have been assigned to the group <strong>{{groupName}}</strong> in the STARR System.</p>
<p>This assignment grants you access to shared resources and collaboration tools within the group.</p>
<p>Please log in to your account to view group details.</p>`,
createdAt: now, updatedAt: now,
},
{
type: 'TASK_ASSIGNED', label: 'Task Assigned', is_system: true,
subject: 'New Task Assignment - STARR System',
html_body: `<p>Dear User,</p>
<p>You have been assigned a new task in the STARR System.</p>
<table style="${FONT}">
<tr><td><strong>Task</strong></td><td>{{taskTitle}}</td></tr>
<tr><td><strong>Due Date</strong></td><td>{{dueDate}}</td></tr>
</table>
<p>Kindly ensure completion within the specified timeframe.</p>`,
createdAt: now, updatedAt: now,
},
{
type: 'BAN_LIFTED', label: 'Ban Lifted', is_system: true,
subject: 'Account Suspension Lifted - STARR System',
html_body: `<p>Dear {{name}},</p>
<p>We are writing to inform you that the suspension on your account (<strong>{{email}}</strong>) has been lifted effective <strong>{{date}}</strong>.</p>
<p>You may now log in and resume access to all services within the STARR System.</p>
<p>If you have any concerns, please do not hesitate to contact your administrator.</p>`,
createdAt: now, updatedAt: now,
},
{
type: 'BANNED', label: 'Account Banned', is_system: true,
subject: 'Account Suspension Notice - STARR System',
html_body: `<p>Dear {{name}},</p>
<p>Your account (<strong>{{email}}</strong>) has been {{duration_word}} suspended from the STARR System effective <strong>{{date}}</strong>.</p>
<table style="${FONT}">
<tr><td><strong>Reason</strong></td><td>{{reason}}</td></tr>
<tr><td><strong>Duration</strong></td><td>{{duration_label}}</td></tr>
</table>
<p>During this period, access to all system services has been revoked.{{suspension_note}}</p>
<p>If you believe this was made in error, please contact your administrator.</p>`,
createdAt: now, updatedAt: now,
},
{
type: 'ADD_STAFF', label: 'Staff Account Created', is_system: true,
subject: 'Your Staff Account Has Been Created - STARR System',
html_body: `<p>Dear {{name}},</p>
<p>Your staff account has been successfully created in the STARR System. Below are your login credentials:</p>
<table style="${FONT}">
<tr><td><strong>Email</strong></td><td>{{email}}</td></tr>
<tr><td><strong>Password</strong></td><td>{{password}}</td></tr>
</table>
<p>This temporary password is valid for <strong>{{expiryHours}} hours</strong>. You will be required to change it upon first login.</p>
<p>If you did not request this account or believe this was created in error, please contact your administrator immediately to have it deactivated.</p>
<p>For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.</p>`,
createdAt: now, updatedAt: now,
},
]);
},
async down(queryInterface) {
await queryInterface.dropTable('email_templates');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('email_templates', 'category', {
type: Sequelize.ENUM('announcement', 'advertisement', 'system', 'other'),
allowNull: false,
defaultValue: 'other',
});
// The 8 built-in (is_system) templates are all account/auth notifications.
await queryInterface.sequelize.query(`
UPDATE email_templates SET category = 'system' WHERE is_system = true;
`);
},
async down(queryInterface) {
await queryInterface.removeColumn('email_templates', 'category');
await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_templates_category";`);
},
};
@@ -0,0 +1,46 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Live `subject`/`html_body` become nullable — a brand-new template can
// now exist as a pure draft with no live content at all until it's sent.
await queryInterface.changeColumn('email_templates', 'subject', {
type: Sequelize.STRING(255), allowNull: true,
});
await queryInterface.changeColumn('email_templates', 'html_body', {
type: Sequelize.TEXT, allowNull: true,
});
await queryInterface.addColumn('email_templates', 'status', {
type: Sequelize.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft',
});
// Edits to a 'sent' template land here first — sendEmail() only ever reads
// the live subject/html_body columns, never these — until an admin
// explicitly re-sends (publishes), the pending edit can't affect real mail.
await queryInterface.addColumn('email_templates', 'draft_subject', {
type: Sequelize.STRING(255), allowNull: true,
});
await queryInterface.addColumn('email_templates', 'draft_html_body', {
type: Sequelize.TEXT, allowNull: true,
});
await queryInterface.addColumn('email_templates', 'last_sent_at', {
type: Sequelize.DATE, allowNull: true,
});
// Every row created before this migration already had required subject/
// html_body — meaning it was already "operating" in the old single-state
// world. Backfill them all as sent.
await queryInterface.sequelize.query(`
UPDATE email_templates SET status = 'sent', last_sent_at = "updatedAt";
`);
},
async down(queryInterface) {
await queryInterface.removeColumn('email_templates', 'last_sent_at');
await queryInterface.removeColumn('email_templates', 'draft_html_body');
await queryInterface.removeColumn('email_templates', 'draft_subject');
await queryInterface.removeColumn('email_templates', 'status');
await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_templates_status";`);
},
};
@@ -0,0 +1,47 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('email_broadcasts', {
email_broadcast_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
email_template_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'email_templates', key: 'email_template_id' } },
target_type: { type: Sequelize.ENUM('admin', 'user', 'both', 'task_list', 'course', 'tier_plan'), allowNull: false },
target_id: { type: Sequelize.STRING(64), allowNull: true },
status: { type: Sequelize.ENUM('queued', 'sending', 'completed', 'canceled'), allowNull: false, defaultValue: 'queued' },
total_recipients: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
sent_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
failed_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
started_at: { type: Sequelize.DATE, allowNull: true },
completed_at: { type: Sequelize.DATE, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('email_broadcasts', ['status']);
await queryInterface.createTable('email_broadcast_recipients', {
email_broadcast_recipient_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
email_broadcast_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'email_broadcasts', key: 'email_broadcast_id' } },
user_id: { type: Sequelize.BIGINT, allowNull: true },
email: { type: Sequelize.STRING(255), allowNull: false },
name: { type: Sequelize.STRING(255), allowNull: true },
status: { type: Sequelize.ENUM('pending', 'sent', 'failed'), allowNull: false, defaultValue: 'pending' },
error: { type: Sequelize.TEXT, allowNull: true },
sent_at: { type: Sequelize.DATE, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
// The cron's core query: "give me the next pending batch for a broadcast".
await queryInterface.addIndex('email_broadcast_recipients', ['email_broadcast_id', 'status']);
},
async down(queryInterface) {
await queryInterface.dropTable('email_broadcast_recipients');
await queryInterface.dropTable('email_broadcasts');
await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_broadcast_recipients_status";`);
await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_broadcasts_status";`);
await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_broadcasts_target_type";`);
},
};
@@ -0,0 +1,24 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// The admin editor now authors in Markdown (converted client-side to the
// HTML that subject/html_body already require) — these columns retain the
// original Markdown purely so reopening a template for editing shows the
// human-readable source again instead of the compiled HTML. Templates
// created before this migration (all 8 system templates included) have
// no Markdown source — html_body/draft_html_body remain hand-written HTML
// for them, and the editor falls back to editing that directly.
await queryInterface.addColumn('email_templates', 'body_markdown', {
type: Sequelize.TEXT, allowNull: true,
});
await queryInterface.addColumn('email_templates', 'draft_body_markdown', {
type: Sequelize.TEXT, allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('email_templates', 'draft_body_markdown');
await queryInterface.removeColumn('email_templates', 'body_markdown');
},
};
+23 -12
View File
@@ -11,21 +11,31 @@ const Advertisement = sequelize.define("Advertisement", {
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true }, uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true },
// ─── Identification / placement ────────────────────────────────────────── // ─── Identification / placement ──────────────────────────────────────────
// placement is the source of truth for "where" (a registry key, see
// advertisements.placements.js); type is denormalized from it on every
// write (applyAdvertisementFields) and describes "what it looks like".
placement: {
type: DataTypes.STRING(100),
allowNull: true,
filterable: true,
label: "Placement", order: 1
},
type: { type: {
type: DataTypes.ENUM("hero", "banner", "popup", "sidebar"), type: DataTypes.ENUM("hero", "banner", "popup", "sidebar"),
allowNull: false, allowNull: false,
label: "Type", order: 1 filterable: true,
label: "Format", order: 2
}, },
status: { status: {
type: DataTypes.ENUM("draft", "active", "scheduled", "expired", "archived"), type: DataTypes.ENUM("draft", "active", "scheduled", "expired", "archived"),
allowNull: false, allowNull: false,
defaultValue: "draft", label: "Status", order: 2 defaultValue: "draft", label: "Status", order: 3
}, },
// ─── Content ────────────────────────────────────────────────────────────── // ─── Content ──────────────────────────────────────────────────────────────
badge_label: { type: DataTypes.STRING(100), label: "Badge Label", order: 3 }, badge_label: { type: DataTypes.STRING(100), label: "Badge Label", order: 4 },
headline: { type: DataTypes.STRING(255), label: "Headline", order: 4 }, headline: { type: DataTypes.STRING(255), label: "Headline", order: 5 },
description: { type: DataTypes.TEXT, label: "Description", order: 5 }, description: { type: DataTypes.TEXT, label: "Description", order: 6 },
// ─── Media ──────────────────────────────────────────────────────────────── // ─── Media ────────────────────────────────────────────────────────────────
image_url: { type: DataTypes.STRING(512), label: "Image URL", order: 0, hidden: true }, image_url: { type: DataTypes.STRING(512), label: "Image URL", order: 0, hidden: true },
@@ -33,19 +43,19 @@ const Advertisement = sequelize.define("Advertisement", {
// ─── Calls-to-action ────────────────────────────────────────────────────── // ─── Calls-to-action ──────────────────────────────────────────────────────
// [{ label, link }, ...] — 0-2 entries depending on type // [{ label, link }, ...] — 0-2 entries depending on type
ctas: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "CTAs", order: 6 }, ctas: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "CTAs", order: 7 },
// ─── Scheduling ─────────────────────────────────────────────────────────── // ─── Scheduling ───────────────────────────────────────────────────────────
start_date: { type: DataTypes.DATE, label: "Start Date", order: 7 }, start_date: { type: DataTypes.DATE, label: "Start Date", order: 8 },
end_date: { type: DataTypes.DATE, label: "End Date", order: 8 }, end_date: { type: DataTypes.DATE, label: "End Date", order: 9 },
// ─── Display behavior ───────────────────────────────────────────────────── // ─── Display behavior ─────────────────────────────────────────────────────
order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: "order", label: "Order", order: 9 }, order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: "order", label: "Order", order: 10 },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Active", order: 10 }, is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Active", order: 11 },
size: { type: DataTypes.ENUM("sm", "md", "lg"), allowNull: true, label: "Size", order: 11 }, // banner-only, ignored by other types size: { type: DataTypes.ENUM("sm", "md", "lg"), allowNull: true, label: "Size", order: 12 }, // banner-only, ignored by other types
// ─── Metrics ────────────────────────────────────────────────────────────── // ─── Metrics ──────────────────────────────────────────────────────────────
click_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Clicks", order: 12, hidden: true }, click_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Clicks", order: 13, hidden: true },
// ─── Audit trails ───────────────────────────────────────────────────────── // ─── Audit trails ─────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
@@ -58,6 +68,7 @@ const Advertisement = sequelize.define("Advertisement", {
indexes: [ indexes: [
{ fields: ["uuid"] }, { fields: ["uuid"] },
{ fields: ["type"] }, { fields: ["type"] },
{ fields: ["placement"] },
{ fields: ["status"] }, { fields: ["status"] },
{ fields: ["is_active"] }, { fields: ["is_active"] },
{ fields: ["deletedAt"] }, { fields: ["deletedAt"] },
@@ -0,0 +1,31 @@
// models/advertisements/advertisements.placements.js
//
// Declarative registry of every ad placement in the client app. Each entry
// is a self-contained "slot" — a page + position pair — that determines the
// visual format (hero/banner/popup/sidebar) automatically. Adding a new
// placement should only ever require adding one entry here (and wiring the
// corresponding client page to fetch/render it) — nothing else in this file
// should need to change.
//
// `key` is what's stored on advertisements.placement. `format` is what gets
// denormalized onto advertisements.type on write (see applyAdvertisementFields
// in controllers/admin/advertisements.controller.js) — type is never accepted
// from the client once a placement is set.
const PLACEMENTS = [
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
{ key: "dashboard.popup", format: "popup", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Popup (on load)" },
{ key: "course_list.banner", format: "banner", page: "course_list", pageLabel: "Courses", slotLabel: "Banner (above course grid)" },
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" },
{ key: "course_details.sidebar", format: "sidebar", page: "course_details", pageLabel: "Course Details", slotLabel: "Sidebar (beside course content)" },
{ key: "plans.banner", format: "banner", page: "plans", pageLabel: "Plans", slotLabel: "Banner (above plan cards)" },
];
const PLACEMENT_MAP = Object.fromEntries(PLACEMENTS.map((p) => [p.key, p]));
const PLACEMENT_KEYS = PLACEMENTS.map((p) => p.key);
function getFormatForPlacement(key) {
return PLACEMENT_MAP[key]?.format ?? null;
}
module.exports = { PLACEMENTS, PLACEMENT_MAP, PLACEMENT_KEYS, getFormatForPlacement };
+3 -3
View File
@@ -1,10 +1,10 @@
/*********************************************************************************************************************************************************************** /***********************************************************************************************************************************************************************
* File Name: pending_certificate.mdl.js * File Name: pending_certificate.mdl.js
* Type of Program: Model * Type of Program: Model
* Description: Holds certificates queued for issuance after a 45-minute delay * Description: Holds certificates queued for issuance after a 5-minute delay
* following a passed course assessment. The cron job * following a passed course assessment. The cron job
* (cron/jobs/issue_certificates.cron.js) polls this table every * (cron/jobs/issue_certificates.cron.js) runs hourly on the hour
* 5 minutes and processes rows where issue_at <= NOW(). * and processes rows where issue_at <= NOW().
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 24, 2026 * Date Created: Jun. 24, 2026
@@ -0,0 +1,38 @@
/***********************************************************************************************************************************************************************
* File Name: email_broadcast.mdl.js
* Type of Program: Model
* Description: One "send this email template to this audience" job. The API
* only ever creates this row + its email_broadcast_recipients
* rows (see controllers/admin/email_broadcasts.controller.js) —
* actual SMTP sending happens later, paced, in
* cron/jobs/dispatch_email_broadcasts.cron.js.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 3, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_EmailTemplate = require('./email_templates.mdl');
const mdl_Users = require('../users/users.mdl');
const mdl_EmailBroadcast = sequelize.define('EmailBroadcast', {
email_broadcast_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 },
email_template_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Template', order: 1 },
target_type: { type: DataTypes.ENUM('admin', 'user', 'both', 'task_list', 'course', 'tier_plan'), allowNull: false, label: 'Target', order: 2, filterable: true },
target_id: { type: DataTypes.STRING(64), allowNull: true, label: 'Target ID', order: 3 },
status: { type: DataTypes.ENUM('queued', 'sending', 'completed', 'canceled'), allowNull: false, defaultValue: 'queued', label: 'Status', order: 4, filterable: true },
total_recipients: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: 'Total', order: 5 },
sent_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: 'Sent', order: 6 },
failed_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: 'Failed', order: 7 },
started_at: { type: DataTypes.DATE, allowNull: true, label: 'Started', order: 8 },
completed_at: { type: DataTypes.DATE, allowNull: true, label: 'Completed', order: 9 },
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Created By' },
}, {
tableName: 'email_broadcasts',
timestamps: true,
paranoid: false,
});
mdl_EmailBroadcast.belongsTo(mdl_EmailTemplate, { as: 'template', foreignKey: 'email_template_id' });
mdl_EmailBroadcast.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' });
module.exports = mdl_EmailBroadcast;
@@ -0,0 +1,33 @@
/***********************************************************************************************************************************************************************
* File Name: email_broadcast_recipient.mdl.js
* Type of Program: Model
* Description: The outbox — one row per recipient of an email_broadcasts job.
* `email` is snapshotted at enqueue time so a later change to the
* user's account email doesn't affect an in-flight broadcast.
* cron/jobs/dispatch_email_broadcasts.cron.js is the only writer
* of `status`/`error`/`sent_at` after creation.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 3, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_EmailBroadcast = require('./email_broadcast.mdl');
const mdl_EmailBroadcastRecipient = sequelize.define('EmailBroadcastRecipient', {
email_broadcast_recipient_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 },
email_broadcast_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Broadcast', order: 1 },
user_id: { type: DataTypes.BIGINT, allowNull: true, label: 'User', order: 2 },
email: { type: DataTypes.STRING(255), allowNull: false, label: 'Email', order: 3 },
name: { type: DataTypes.STRING(255), allowNull: true, label: 'Name', order: 4 },
status: { type: DataTypes.ENUM('pending', 'sent', 'failed'), allowNull: false, defaultValue: 'pending', label: 'Status', order: 5, filterable: true },
error: { type: DataTypes.TEXT, allowNull: true, label: 'Error', order: 6 },
sent_at: { type: DataTypes.DATE, allowNull: true, label: 'Sent At', order: 7 },
}, {
tableName: 'email_broadcast_recipients',
timestamps: true,
paranoid: false,
});
mdl_EmailBroadcastRecipient.belongsTo(mdl_EmailBroadcast, { as: 'broadcast', foreignKey: 'email_broadcast_id' });
module.exports = mdl_EmailBroadcastRecipient;
@@ -0,0 +1,49 @@
/***********************************************************************************************************************************************************************
* File Name: email_templates.mdl.js
* Type of Program: Model
* Description: Admin-managed catalog of email templates (subject + HTML body).
* Replaces the old static emailTemplates map in data/email_body.data.js.
* `is_system` rows are the built-in types referenced by name in
* services/email.service.js's sendEmail() callers — protected
* from deletion/type-rename by the admin controller. The outer
* layout (header/footer/signature) is NOT stored here — it stays
* fixed in services/email.service.js and is never admin-editable.
*
* Publish workflow: `subject`/`html_body` are the LIVE content —
* the only columns services/email.service.js's sendEmail() ever
* reads. Editing a 'sent' template writes to `draft_subject`/
* `draft_html_body` instead, leaving live content (and therefore
* real outgoing mail) untouched until an admin explicitly
* publishes again (see controllers/admin/email_templates.controller.js).
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 3, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_EmailTemplate = sequelize.define('EmailTemplate', {
email_template_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 },
type: { type: DataTypes.STRING(100), allowNull: false, unique: true, label: 'Type', hidden: false, order: 1, filterable: true },
label: { type: DataTypes.STRING(150), allowNull: false, label: 'Label', hidden: false, order: 2, filterable: true },
category: { type: DataTypes.ENUM('announcement', 'advertisement', 'system', 'other'), allowNull: false, defaultValue: 'other', label: 'Category', hidden: false, order: 3, filterable: true },
status: { type: DataTypes.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft', label: 'Status', hidden: false, order: 4, filterable: true },
subject: { type: DataTypes.STRING(255), allowNull: true, label: 'Subject', hidden: false, order: 5, filterable: false },
html_body: { type: DataTypes.TEXT, allowNull: true, label: 'HTML Body', hidden: false, order: 6, filterable: false },
// Markdown source for the live/draft HTML above — editor convenience only,
// never read by services/email.service.js. Null for templates authored
// before Markdown support (including all 8 system templates), which keep
// editing html_body/draft_html_body directly.
body_markdown: { type: DataTypes.TEXT, allowNull: true, label: 'Body (Markdown)', hidden: false, order: 6.5, filterable: false },
draft_subject: { type: DataTypes.STRING(255), allowNull: true, label: 'Draft Subject', hidden: false, order: 7, filterable: false },
draft_html_body: { type: DataTypes.TEXT, allowNull: true, label: 'Draft Body', hidden: false, order: 8, filterable: false },
draft_body_markdown: { type: DataTypes.TEXT, allowNull: true, label: 'Draft Body (Markdown)', hidden: false, order: 8.5, filterable: false },
last_sent_at: { type: DataTypes.DATE, allowNull: true, label: 'Last Sent', hidden: false, order: 9, filterable: false },
is_system: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'System', hidden: false, order: 10, filterable: true },
}, {
tableName: 'email_templates',
timestamps: true,
paranoid: false,
});
module.exports = mdl_EmailTemplate;
@@ -0,0 +1,15 @@
// models/notifications/cron_notification_setting.mdl.js
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const CronNotificationSetting = sequelize.define("CronNotificationSetting", {
job_name: { type: DataTypes.STRING(64), primaryKey: true, label: "Job" },
enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Enabled" },
schedule: { type: DataTypes.STRING(20), allowNull: false, label: "Schedule" },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
}, {
tableName: "cron_notification_settings",
timestamps: true,
});
module.exports = CronNotificationSetting;
@@ -0,0 +1,24 @@
// models/notifications/notification_broadcast.attributes.js
// ─── Exclude sets ─────────────────────────────────────────────────────────────
const excludeAttributes = [];
// Admins see everything
const adminExclude = [
...excludeAttributes,
];
// ─── JSONB schemas ──────────────────────────────────────────────────────────
// No JSONB columns on this model.
const jsonbSchemas = {};
// ─── Computed attributes ──────────────────────────────────────────────────────
const computedAttributes = [];
module.exports = {
excludeAttributes,
adminExclude,
jsonbSchemas,
computedAttributes,
};
@@ -0,0 +1,54 @@
// models/notifications/notification_broadcast.mdl.js
const { DataTypes } = require("sequelize");
const sequelize = require("../../config/db.config");
const mdl_Users = require("../users/users.mdl");
const NotificationBroadcast = sequelize.define("NotificationBroadcast", {
// ─── Identity ─────────────────────────────────────────────────────────────
broadcast_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Broadcast ID", order: 0, hidden: true },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true },
// ─── Content ──────────────────────────────────────────────────────────────
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 },
message: { type: DataTypes.TEXT, allowNull: false, label: "Message", order: 2 },
// ─── Targeting ────────────────────────────────────────────────────────────
target_type: {
type: DataTypes.ENUM("admin", "user", "both", "task_list", "course", "tier_plan"),
allowNull: false,
label: "Target", order: 3
},
// Holds a task list UUID, course UUID, or tier plan ID (stringified) — only
// set when target_type is 'task_list' / 'course' / 'tier_plan'.
target_id: { type: DataTypes.STRING(64), allowNull: true, label: "Target ID", order: 3.5 },
// ─── Lifecycle ────────────────────────────────────────────────────────────
status: {
type: DataTypes.ENUM("draft", "sent", "archived"),
allowNull: false,
defaultValue: "draft", label: "Status", order: 4
},
sent_at: { type: DataTypes.DATE, allowNull: true, label: "Sent At", order: 5 },
recipient_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Recipients", order: 6 },
// ─── Audit trails ─────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
}, {
tableName: "notification_broadcasts",
timestamps: true, // createdAt, updatedAt
paranoid: true,
indexes: [
{ fields: ["uuid"] },
{ fields: ["status"] },
{ fields: ["target_type"] },
{ fields: ["deletedAt"] },
],
});
NotificationBroadcast.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
NotificationBroadcast.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
module.exports = NotificationBroadcast;
+4 -1
View File
@@ -8,6 +8,9 @@
const excludeAttributes = ['provider_payload']; const excludeAttributes = ['provider_payload'];
const jsonbSchemas = {}; const jsonbSchemas = {};
const computedAttributes = []; const computedAttributes = [
{ key: 'user_full_name', label: 'Full Name', type: 'text', order: 1, filterable: false, literal: `("user"."personal_info"->'name'->>'full_name')` },
{ key: 'user.email', label: 'Email Address', type: 'text', order: 2, filterable: false },
];
module.exports = { excludeAttributes, jsonbSchemas, computedAttributes }; module.exports = { excludeAttributes, jsonbSchemas, computedAttributes };
+2 -2
View File
@@ -25,13 +25,13 @@ const mdl_Payments = sequelize.define('Payment', {
plan_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Plan ID', hidden: true, order: 2, filterable: true }, plan_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Plan ID', hidden: true, order: 2, filterable: true },
tier_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Tier ID', hidden: true, order: 3, filterable: true }, tier_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Tier ID', hidden: true, order: 3, filterable: true },
status: { type: DataTypes.ENUM('pending', 'completed', 'failed', 'cancelled', 'expired', 'refunded'), allowNull: false, defaultValue: 'pending', label: 'Status', hidden: false, order: 4, filterable: true }, status: { type: DataTypes.ENUM('pending', 'completed', 'failed', 'cancelled', 'expired', 'refunded'), allowNull: false, defaultValue: 'pending', label: 'Status', hidden: false, order: 4, filterable: true },
amount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Amount', hidden: false, order: 5, filterable: false }, amount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Amount', hidden: false, order: 3, filterable: false },
currency: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'USD', label: 'Currency', hidden: false, order: 6, filterable: true }, currency: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'USD', label: 'Currency', hidden: false, order: 6, filterable: true },
promo_code: { type: DataTypes.STRING(50), allowNull: true, label: 'Promo Code', hidden: false, order: 7, filterable: true }, promo_code: { type: DataTypes.STRING(50), allowNull: true, label: 'Promo Code', hidden: false, order: 7, filterable: true },
discount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, defaultValue: 0.00, label: 'Discount', hidden: false, order: 8, filterable: false }, discount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, defaultValue: 0.00, label: 'Discount', hidden: false, order: 8, filterable: false },
provider: { type: DataTypes.STRING(50), allowNull: false, defaultValue: 'paypal', label: 'Provider', hidden: false, order: 9, filterable: true }, provider: { type: DataTypes.STRING(50), allowNull: false, defaultValue: 'paypal', label: 'Provider', hidden: false, order: 9, filterable: true },
provider_payload: { type: DataTypes.JSONB, allowNull: true, defaultValue: {}, label: 'Provider Payload', hidden: true, order: 10, filterable: false }, provider_payload: { type: DataTypes.JSONB, allowNull: true, defaultValue: {}, label: 'Provider Payload', hidden: true, order: 10, filterable: false },
paid_at: { type: DataTypes.DATE, allowNull: true, label: 'Paid At', hidden: false, order: 11, filterable: false }, paid_at: { type: DataTypes.DATE, allowNull: true, label: 'Paid At', hidden: false, order: 5, filterable: false },
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Created By' }, createdBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Created By' },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Updated By' }, updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Updated By' },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Deleted By' }, deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Deleted By' },
-26
View File
@@ -1,26 +0,0 @@
/***********************************************************************************************************************************************************************
* File Name: plan_prices.mdl.js
* Type of Program: Model
* Description: Admin-managed localized price overrides for tier plans.
* One row per (plan_id, currency) pair. When a user's preferred_currency
* matches a row here, the override price is shown instead of the base price.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 29, 2026
***********************************************************************************************************************************************************************/
'use strict';
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_PlanPrices = sequelize.define('PlanPrice', {
price_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: { type: DataTypes.BIGINT, allowNull: false },
currency: { type: DataTypes.CHAR(3), allowNull: false, label: 'Currency' },
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' },
}, {
tableName: 'plan_prices',
timestamps: true,
paranoid: false,
});
module.exports = mdl_PlanPrices;
-6
View File
@@ -5,7 +5,6 @@ const mdl_UserTiers = require('./user_tiers.mdl');
const mdl_Payments = require('./payments.mdl'); const mdl_Payments = require('./payments.mdl');
const mdl_PlanCourses = require('./plan_courses.mdl'); const mdl_PlanCourses = require('./plan_courses.mdl');
const mdl_PlanPolicies = require('./plan_policies.mdl'); const mdl_PlanPolicies = require('./plan_policies.mdl');
const mdl_PlanPrices = require('./plan_prices.mdl');
const mdl_SystemBadges = require('../system_badges/system_badges.mdl'); const mdl_SystemBadges = require('../system_badges/system_badges.mdl');
const Asset = require('../assets/assets.mdl'); const Asset = require('../assets/assets.mdl');
const { Course } = require('../courses/courses.mdl'); const { Course } = require('../courses/courses.mdl');
@@ -54,10 +53,6 @@ mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers'
mdl_TierPlans.hasOne(mdl_PlanPolicies, { foreignKey: 'plan_id', as: 'policy' }); mdl_TierPlans.hasOne(mdl_PlanPolicies, { foreignKey: 'plan_id', as: 'policy' });
mdl_PlanPolicies.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_PlanPolicies.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
// ─── Plan ↔ Localized Prices ──────────────────────────────────────────────────
mdl_TierPlans.hasMany(mdl_PlanPrices, { foreignKey: 'plan_id', as: 'prices' });
mdl_PlanPrices.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
// ─── SystemBadge → Asset ───────────────────────────────────────────────────── // ─── SystemBadge → Asset ─────────────────────────────────────────────────────
mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' }); mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' });
@@ -68,6 +63,5 @@ module.exports = {
mdl_Payments, mdl_Payments,
mdl_PlanCourses, mdl_PlanCourses,
mdl_PlanPolicies, mdl_PlanPolicies,
mdl_PlanPrices,
mdl_SystemBadges, mdl_SystemBadges,
}; };
@@ -0,0 +1,32 @@
/***********************************************************************************************************************************************************************
* File Name: achievement_definitions.mdl.js
* Type of Program: Model
* Description: Admin-managed catalog of achievements (badges and milestones).
* Replaces the old static ACHIEVEMENT_REGISTRY in data/achievements.data.js.
* `is_system` rows are the built-in keys referenced by name in
* services/achievements.service.js's trigger functions — protected
* from deletion/key-rename by the admin controller.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 3, 2026
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_AchievementDefinitions = sequelize.define('AchievementDefinition', {
achievement_definition_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 },
key: { type: DataTypes.STRING(100), allowNull: false, unique: true, label: 'Key', hidden: false, order: 1, filterable: true },
type: { type: DataTypes.ENUM('badge', 'milestone'), allowNull: false, defaultValue: 'badge', label: 'Type', hidden: false, order: 2, filterable: true },
label: { type: DataTypes.STRING(255), allowNull: false, label: 'Label', hidden: false, order: 3, filterable: true },
description: { type: DataTypes.TEXT, allowNull: true, label: 'Description', hidden: false, order: 4, filterable: false },
icon: { type: DataTypes.STRING(50), allowNull: true, label: 'Icon', hidden: false, order: 5, filterable: false },
trigger: { type: DataTypes.STRING(30), allowNull: true, label: 'Trigger', hidden: false, order: 6, filterable: true },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active', hidden: false, order: 7, filterable: true },
is_system: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'System', hidden: false, order: 8, filterable: true },
}, {
tableName: 'achievement_definitions',
timestamps: true,
paranoid: false,
});
module.exports = mdl_AchievementDefinitions;
+1
View File
@@ -26,6 +26,7 @@ const mdl_Achievements = sequelize.define('Achievement', {
key: { type: DataTypes.STRING(100), allowNull: false, label: 'Key', hidden: false, order: 2, filterable: true }, key: { type: DataTypes.STRING(100), allowNull: false, label: 'Key', hidden: false, order: 2, filterable: true },
label: { type: DataTypes.STRING(255), allowNull: false, label: 'Label', hidden: false, order: 3, filterable: true }, label: { type: DataTypes.STRING(255), allowNull: false, label: 'Label', hidden: false, order: 3, filterable: true },
description: { type: DataTypes.TEXT, allowNull: true, label: 'Description', hidden: false, order: 4, filterable: false }, description: { type: DataTypes.TEXT, allowNull: true, label: 'Description', hidden: false, order: 4, filterable: false },
icon: { type: DataTypes.STRING(50), allowNull: true, label: 'Icon', hidden: false, order: 5, filterable: false },
granted_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Granted By', hidden: false, order: 5, filterable: false }, granted_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Granted By', hidden: false, order: 5, filterable: false },
granted_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, label: 'Granted At', hidden: false, order: 6, filterable: false }, granted_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, label: 'Granted At', hidden: false, order: 6, filterable: false },
metadata: { type: DataTypes.JSONB, allowNull: true, defaultValue: {}, label: 'Metadata', hidden: true, order: 0 }, metadata: { type: DataTypes.JSONB, allowNull: true, defaultValue: {}, label: 'Metadata', hidden: true, order: 0 },
-3
View File
@@ -43,9 +43,6 @@ const mdl_Users = sequelize.define('User', {
*/ */
personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 }, personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 },
// ── Currency preference ──────────────────────────────────────────────────────
preferred_currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Preferred Currency' },
// ── Ban state ──────────────────────────────────────────────────────────────── // ── Ban state ────────────────────────────────────────────────────────────────
is_banned: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Banned" }, is_banned: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Banned" },
ban_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Ban Expires At" }, ban_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Ban Expires At" },
+26
View File
@@ -16,6 +16,7 @@
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"csurf": "^1.11.0", "csurf": "^1.11.0",
"currency-codes": "^2.2.0",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^6.10.0", "express-rate-limit": "^6.10.0",
@@ -3358,6 +3359,16 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/currency-codes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/currency-codes/-/currency-codes-2.2.0.tgz",
"integrity": "sha512-vpbQc5sEYHGdTVAYUhHnKv0DWiYLRvzl/KKyqeHzBh7HD/j3UlWoScpZ9tN/jG6w2feddWoObsBbaNVu5yDapg==",
"license": "MIT",
"dependencies": {
"first-match": "~0.0.1",
"nub": "~0.0.0"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -3966,6 +3977,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/first-match": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/first-match/-/first-match-0.0.1.tgz",
"integrity": "sha512-VvKbnaxrC0polTFDC+teKPTdl2mn6B/KUW+WB3C9RzKDeNwbzfLdnUz3FxC+tnjvus6bI0jWrWicQyVIPdS37A==",
"license": "MIT"
},
"node_modules/fluent-ffmpeg": { "node_modules/fluent-ffmpeg": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
@@ -6241,6 +6258,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/nub": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/nub/-/nub-0.0.0.tgz",
"integrity": "sha512-dK0Ss9C34R/vV0FfYJXuqDAqHlaW9fvWVufq9MmGF2umCuDbd5GRfRD9fpi/LiM0l4ZXf8IBB+RYmZExqCrf0w==",
"license": "MIT/X11",
"engines": {
"node": "*"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+4 -1
View File
@@ -23,6 +23,7 @@
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"csurf": "^1.11.0", "csurf": "^1.11.0",
"currency-codes": "^2.2.0",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^6.10.0", "express-rate-limit": "^6.10.0",
@@ -52,7 +53,9 @@
"sequelize-cli": "^6.6.5" "sequelize-cli": "^6.6.5"
}, },
"jest": { "jest": {
"testMatch": ["**/tests/**/*.test.js"], "testMatch": [
"**/tests/**/*.test.js"
],
"forceExit": true "forceExit": true
} }
} }
+14
View File
@@ -0,0 +1,14 @@
'use strict';
const router = require('express').Router();
const ctrl = require('../../controllers/admin/achievements.controller');
// Auth + requireAdmin applied by admin.routes.js
router.get ('/', ctrl.getAchievements);
router.post ('/', ctrl.createAchievement);
router.get ('/:id', ctrl.getAchievement);
router.put ('/:id', ctrl.updateAchievement);
router.delete('/:id', ctrl.deleteAchievement);
module.exports = router;
+10
View File
@@ -37,7 +37,12 @@ const categoriesRoutes = require('./categories.routes');
const productsRoutes = require('./products.routes'); const productsRoutes = require('./products.routes');
const advertisementRoutes = require('./advertisements.routes'); const advertisementRoutes = require('./advertisements.routes');
const notificationRoutes = require('./notifications.routes'); const notificationRoutes = require('./notifications.routes');
const notificationBroadcastRoutes = require('./notificationBroadcasts.routes');
const notificationSettingsRoutes = require('./notificationSettings.routes');
const mediaRoutes = require('./media.routes'); const mediaRoutes = require('./media.routes');
const achievementsRoutes = require('./achievements.routes');
const emailTemplatesRoutes = require('./email_templates.routes');
const emailBroadcastsRoutes = require('./email_broadcasts.routes');
const activityCtrl = require('../../controllers/admin/user_activity.controller'); const activityCtrl = require('../../controllers/admin/user_activity.controller');
// ── Guards — applied to ALL admin routes ────────────────────────────────────── // ── Guards — applied to ALL admin routes ──────────────────────────────────────
@@ -58,7 +63,12 @@ router.use('/categories', categoriesRoutes);
router.use('/products', productsRoutes); router.use('/products', productsRoutes);
router.use('/advertisements', advertisementRoutes); router.use('/advertisements', advertisementRoutes);
router.use('/notifications', notificationRoutes); router.use('/notifications', notificationRoutes);
router.use('/notification-broadcasts', notificationBroadcastRoutes);
router.use('/notification-settings', notificationSettingsRoutes);
router.use('/media', mediaRoutes); router.use('/media', mediaRoutes);
router.use('/achievements', achievementsRoutes);
router.use('/email-templates', emailTemplatesRoutes);
router.use('/email-broadcasts', emailBroadcastsRoutes);
// ── Activity feed — global ──────────────────────────────────────────────────── // ── Activity feed — global ────────────────────────────────────────────────────
router.get('/activity', activityCtrl.getActivity); router.get('/activity', activityCtrl.getActivity);
+4
View File
@@ -28,6 +28,7 @@ router.get("/lessons-flat", ctrl.getLessonsFlat);
// ── then :courseId ──────────────────────────────────────────────────────────── // ── then :courseId ────────────────────────────────────────────────────────────
router.get("/archives/:courseId", ctrl.getArchivedCourse); router.get("/archives/:courseId", ctrl.getArchivedCourse);
router.patch("/:courseId/restore", ctrl.restoreCourse); router.patch("/:courseId/restore", ctrl.restoreCourse);
router.get("/:courseId/archive-impact", ctrl.getCourseArchiveImpact);
router.get("/:courseId", ctrl.getCourse); router.get("/:courseId", ctrl.getCourse);
router.put("/:courseId", ctrl.updateCourse); router.put("/:courseId", ctrl.updateCourse);
router.delete("/:courseId", ctrl.archiveCourse); router.delete("/:courseId", ctrl.archiveCourse);
@@ -75,6 +76,7 @@ router.post("/:courseId/assessment/:assessmentId/questions", ctrl.createQuestion
// static before :questionId // static before :questionId
router.delete("/:courseId/assessment/:assessmentId/questions/bulk", ctrl.bulkArchiveQuestions); router.delete("/:courseId/assessment/:assessmentId/questions/bulk", ctrl.bulkArchiveQuestions);
router.patch("/:courseId/assessment/:assessmentId/questions/restore/bulk", ctrl.bulkRestoreQuestions); router.patch("/:courseId/assessment/:assessmentId/questions/restore/bulk", ctrl.bulkRestoreQuestions);
router.put("/:courseId/assessment/:assessmentId/questions/bulk-sync", ctrl.bulkSyncQuestions);
router.get("/:courseId/assessment/:assessmentId/questions/archives/:questionId", ctrl.getArchivedQuestion); router.get("/:courseId/assessment/:assessmentId/questions/archives/:questionId", ctrl.getArchivedQuestion);
router.patch("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.updateQuestion); router.patch("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.updateQuestion);
@@ -100,6 +102,7 @@ router.patch("/:courseId/units/restore/bulk", ctrl.bulkRestoreUnits);
router.get("/:courseId/units/archives/:unitId", ctrl.getArchivedUnit); router.get("/:courseId/units/archives/:unitId", ctrl.getArchivedUnit);
router.patch("/:courseId/units/:unitId/restore", ctrl.restoreUnit); router.patch("/:courseId/units/:unitId/restore", ctrl.restoreUnit);
router.get("/:courseId/units/:unitId/archive-impact", ctrl.getUnitArchiveImpact);
router.get("/:courseId/units/:unitId", ctrl.getUnit); router.get("/:courseId/units/:unitId", ctrl.getUnit);
router.put("/:courseId/units/:unitId", ctrl.updateUnit); router.put("/:courseId/units/:unitId", ctrl.updateUnit);
router.delete("/:courseId/units/:unitId", ctrl.archiveUnit); router.delete("/:courseId/units/:unitId", ctrl.archiveUnit);
@@ -125,6 +128,7 @@ router.post("/:courseId/units/:unitId/quiz/:quizId/questions", ctrl.createQuesti
// static before :questionId // static before :questionId
router.delete("/:courseId/units/:unitId/quiz/:quizId/questions/bulk", ctrl.bulkArchiveQuestions); router.delete("/:courseId/units/:unitId/quiz/:quizId/questions/bulk", ctrl.bulkArchiveQuestions);
router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/restore/bulk", ctrl.bulkRestoreQuestions); router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/restore/bulk", ctrl.bulkRestoreQuestions);
router.put("/:courseId/units/:unitId/quiz/:quizId/questions/bulk-sync", ctrl.bulkSyncQuestions);
router.get("/:courseId/units/:unitId/quiz/:quizId/questions/archives/:questionId", ctrl.getArchivedQuestion); router.get("/:courseId/units/:unitId/quiz/:quizId/questions/archives/:questionId", ctrl.getArchivedQuestion);
router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId", ctrl.updateQuestion); router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId", ctrl.updateQuestion);
+13
View File
@@ -0,0 +1,13 @@
'use strict';
const router = require('express').Router();
const ctrl = require('../../controllers/admin/email_broadcasts.controller');
// Auth + requireAdmin applied by admin.routes.js
router.get ('/', ctrl.getEmailBroadcasts);
router.post ('/', ctrl.createEmailBroadcast);
router.get ('/:id', ctrl.getEmailBroadcast);
router.patch ('/:id/cancel', ctrl.cancelEmailBroadcast);
module.exports = router;
+14
View File
@@ -0,0 +1,14 @@
'use strict';
const router = require('express').Router();
const ctrl = require('../../controllers/admin/email_templates.controller');
// Auth + requireAdmin applied by admin.routes.js
router.get ('/', ctrl.getEmailTemplates);
router.post ('/', ctrl.createEmailTemplate);
router.get ('/:id', ctrl.getEmailTemplate);
router.put ('/:id', ctrl.updateEmailTemplate);
router.delete('/:id', ctrl.deleteEmailTemplate);
module.exports = router;
@@ -0,0 +1,22 @@
// routes/admin/notificationBroadcasts.routes.js
const router = require('express').Router();
const controller = require('../../controllers/admin/notificationBroadcasts.controller');
const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
// ─── Static routes first ──────────────────────────────────────────────────────
router.get('/archived', controller.getArchivedBroadcasts);
router.delete('/bulk', sensitiveOpsLimiter, controller.archiveBroadcasts);
router.patch('/bulk-restore', controller.restoreBroadcasts);
// ─── Collection ───────────────────────────────────────────────────────────────
router.get('/', controller.getBroadcasts);
router.post('/', controller.createBroadcast);
// ─── Dynamic routes last ──────────────────────────────────────────────────────
router.get('/:broadcastId', controller.getBroadcast);
router.patch('/:broadcastId', sensitiveOpsLimiter, controller.updateBroadcast);
router.patch('/:broadcastId/send', sensitiveOpsLimiter, controller.sendBroadcast);
router.patch('/:broadcastId/restore', sensitiveOpsLimiter, controller.restoreBroadcast);
router.delete('/:broadcastId', sensitiveOpsLimiter, controller.archiveBroadcast);
module.exports = router;
@@ -0,0 +1,8 @@
// routes/admin/notificationSettings.routes.js
const router = require('express').Router();
const controller = require('../../controllers/admin/notificationSettings.controller');
router.get('/', controller.getSettings);
router.patch('/:jobName', controller.updateSetting);
module.exports = router;
+2 -7
View File
@@ -1,8 +1,8 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const ctrl = require('../../controllers/admin/tiers.controller'); const ctrl = require('../../controllers/admin/tiers.controller');
const priceCtrl = require('../../controllers/admin/plan_prices.controller');
router.get ('/currencies', ctrl.getCurrencies);
router.get ('/', ctrl.getPlans); router.get ('/', ctrl.getPlans);
router.post ('/', ctrl.createPlan); router.post ('/', ctrl.createPlan);
router.get ('/field-values', ctrl.getPlanFieldValues); router.get ('/field-values', ctrl.getPlanFieldValues);
@@ -17,16 +17,11 @@ router.get ('/users/:id/tiers', ctrl.getUserTiers);
router.post ('/users/tiers/grant', ctrl.grantTier); router.post ('/users/tiers/grant', ctrl.grantTier);
router.patch ('/users/tiers/:tid/revoke', ctrl.revokeTier); router.patch ('/users/tiers/:tid/revoke', ctrl.revokeTier);
// ← course + impact + prices routes before /:id // ← course + impact routes before /:id
router.get ('/:id/impact', ctrl.getPlanImpact); router.get ('/:id/impact', ctrl.getPlanImpact);
router.get ('/:id/courses', ctrl.getPlanCourses); router.get ('/:id/courses', ctrl.getPlanCourses);
router.post ('/:id/courses', ctrl.syncPlanCourses); router.post ('/:id/courses', ctrl.syncPlanCourses);
router.get ('/:id/prices', priceCtrl.getPrices);
router.post ('/:id/prices', priceCtrl.addPrice);
router.put ('/:id/prices/:currency', priceCtrl.updatePrice);
router.delete('/:id/prices/:currency', priceCtrl.removePrice);
router.get ('/:id', ctrl.getPlan); router.get ('/:id', ctrl.getPlan);
router.put ('/:id', ctrl.updatePlan); router.put ('/:id', ctrl.updatePlan);
router.delete('/:id', ctrl.archivePlan); router.delete('/:id', ctrl.archivePlan);
+4 -1
View File
@@ -2,9 +2,12 @@
const router = require('express').Router(); const router = require('express').Router();
const controller = require('../../controllers/client/advertisements.controller'); const controller = require('../../controllers/client/advertisements.controller');
// ─── GET /api/client/advertisements/active?type=hero ────────────────────────── // ─── GET /api/client/advertisements/active?placement=dashboard.hero ───────────
router.get('/active', controller.getActiveAdvertisement); router.get('/active', controller.getActiveAdvertisement);
// ─── GET /api/client/advertisements/active-batch?placements=a,b,c ─────────────
router.get('/active-batch', controller.getActiveAdvertisements);
// ─── POST /api/client/advertisements/:advertisementId/click ─────────────────── // ─── POST /api/client/advertisements/:advertisementId/click ───────────────────
router.post('/:advertisementId/click', controller.trackClick); router.post('/:advertisementId/click', controller.trackClick);
-1
View File
@@ -50,7 +50,6 @@ router.use(authenticate, requireClient());
router.get('/profile', profileCtrl.getProfile); router.get('/profile', profileCtrl.getProfile);
router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile); router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile);
router.delete('/profile', profileCtrl.deleteAccount); router.delete('/profile', profileCtrl.deleteAccount);
router.patch('/profile/currency', profileCtrl.updateCurrency);
router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar); router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar);
router.delete('/profile/avatar', profileCtrl.deleteAvatar); router.delete('/profile/avatar', profileCtrl.deleteAvatar);
router.get('/sessions', profileCtrl.getSessions); router.get('/sessions', profileCtrl.getSessions);
+3 -1
View File
@@ -7,6 +7,7 @@
* GET /api/client/notifications/unseen — unseen count * GET /api/client/notifications/unseen — unseen count
* PATCH /api/client/notifications/seen-all — mark all seen * PATCH /api/client/notifications/seen-all — mark all seen
* PATCH /api/client/notifications/:id/seen — mark one seen * PATCH /api/client/notifications/:id/seen — mark one seen
* DELETE /api/client/notifications/clear-all — delete all notifications
* *
* Guards: inherited from client.routes.js (authenticate → requireClient) * Guards: inherited from client.routes.js (authenticate → requireClient)
* *
@@ -15,11 +16,12 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { list, unseenCount, markSeen, markAllSeen } = require('../../controllers/client/notification.controller'); const { list, unseenCount, markSeen, markAllSeen, clearAll } = require('../../controllers/client/notification.controller');
router.get('/', list); router.get('/', list);
router.get('/unseen', unseenCount); router.get('/unseen', unseenCount);
router.patch('/seen-all', markAllSeen); router.patch('/seen-all', markAllSeen);
router.patch('/:id/seen', markSeen); router.patch('/:id/seen', markSeen);
router.delete('/clear-all', clearAll);
module.exports = router; module.exports = router;
-4
View File
@@ -1,7 +1,6 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const ctrl = require('../../controllers/client/tiers.controller'); const ctrl = require('../../controllers/client/tiers.controller');
const priceCtrl = require('../../controllers/admin/plan_prices.controller');
// My tier // My tier
router.get ('/me', ctrl.getMyTier); router.get ('/me', ctrl.getMyTier);
@@ -28,7 +27,4 @@ router.get ('/categories', ctrl.getCategories);
// System badges (public read for profile display) // System badges (public read for profile display)
router.get ('/system-badges', ctrl.getSystemBadges); router.get ('/system-badges', ctrl.getSystemBadges);
// Supported currencies (public — used by currency picker in settings + checkout)
router.get ('/currencies', priceCtrl.getCurrencies);
module.exports = router; module.exports = router;
+3 -2
View File
@@ -44,6 +44,7 @@ require('./models/users/user_sessions.mdl');
require('./models/users/user_groups.mdl'); require('./models/users/user_groups.mdl');
require('./models/notifications/admin_notification.mdl'); require('./models/notifications/admin_notification.mdl');
require('./models/notifications/user_notification.mdl'); require('./models/notifications/user_notification.mdl');
require('./models/notifications/notification_broadcast.mdl');
// ── Cron jobs ────────────────────────────────────────────────────────────────── // ── Cron jobs ──────────────────────────────────────────────────────────────────
const { startAdminCronJobs } = require('./cron/admin.cron'); const { startAdminCronJobs } = require('./cron/admin.cron');
@@ -169,8 +170,8 @@ function printCronTable(jobs) {
console.log('✅ Database connected.'); console.log('✅ Database connected.');
const cronJobs = [ const cronJobs = [
...startAdminCronJobs(), ...(await startAdminCronJobs()),
...startClientCronJobs(), ...(await startClientCronJobs()),
]; ];
app.listen(PORT, () => { app.listen(PORT, () => {
+6 -5
View File
@@ -12,8 +12,9 @@
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const mdl_Achievements = require('../models/users/achievements.mdl'); const mdl_Achievements = require('../models/users/achievements.mdl');
const mdl_AchievementDefinitions = require('../models/users/achievement_definitions.mdl');
const mdl_Users = require('../models/users/users.mdl'); const mdl_Users = require('../models/users/users.mdl');
const { EARLY_ACCESS_CUTOFF, ACHIEVEMENT_REGISTRY } = require('../data/achievements.data'); const { EARLY_ACCESS_CUTOFF } = require('../data/achievements.data');
const UserNotification = require('../models/notifications/user_notification.mdl'); const UserNotification = require('../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data'); const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
@@ -24,15 +25,15 @@ const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
* Safe to call multiple times — idempotent via findOrCreate. * Safe to call multiple times — idempotent via findOrCreate.
* *
* @param {string|number} user_id * @param {string|number} user_id
* @param {string} key — must exist in ACHIEVEMENT_REGISTRY * @param {string} key — must exist in the achievement_definitions catalog
* @param {Object} metadata — optional extra data (course_id, score, etc.) * @param {Object} metadata — optional extra data (course_id, score, etc.)
* @param {string|number} granted_by — null = system, user_id = admin manual grant * @param {string|number} granted_by — null = system, user_id = admin manual grant
* @returns {{ achievement, created }} or null on error * @returns {{ achievement, created }} or null on error
*/ */
async function grantAchievement(user_id, key, metadata = {}, granted_by = null) { async function grantAchievement(user_id, key, metadata = {}, granted_by = null) {
const def = ACHIEVEMENT_REGISTRY[key]; const def = await mdl_AchievementDefinitions.findOne({ where: { key, is_active: true } });
if (!def) { if (!def) {
console.warn(`[ACHIEVEMENTS] Unknown achievement key: "${key}"`); console.warn(`[ACHIEVEMENTS] Unknown or inactive achievement key: "${key}"`);
return null; return null;
} }
@@ -47,6 +48,7 @@ async function grantAchievement(user_id, key, metadata = {}, granted_by = null)
key: def.key, key: def.key,
label: def.label, label: def.label,
description: def.description, description: def.description,
icon: def.icon,
granted_by: granted_by ?? null, granted_by: granted_by ?? null,
granted_at: new Date(), granted_at: new Date(),
metadata, metadata,
@@ -140,7 +142,6 @@ async function backfillEarlyAccess() {
// ─── Exports ────────────────────────────────────────────────────────────────── // ─── Exports ──────────────────────────────────────────────────────────────────
module.exports = { module.exports = {
ACHIEVEMENT_REGISTRY,
grantAchievement, grantAchievement,
// Convenience triggers // Convenience triggers
+107
View File
@@ -0,0 +1,107 @@
/***********************************************************************************************************************************************************************
* File Name : certificate-record.service.js
* Type : Service
* Description : Resolves or lazily creates the persisted `certificates` row for a
* user/course pair (cert_no/ref_no assignment, instructor snapshot).
* Shared by the PDF download endpoint (certificate.controller.js) and
* the hourly issuance cron (cron/jobs/issue_certificates.cron.js) so
* both write through the same cert_no/ref_no sequence.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 2, 2026
***********************************************************************************************************************************************************************/
'use strict';
const { formatDuration } = require('../utils/duration.util');
const {
Course,
CourseAssessment,
QuizAttempt,
Certificate,
CourseInstructor,
} = require('../models/courses/courses.associations');
function formatInstructors(rows) {
const names = rows.map(r => r.display_name);
if (names.length === 0) return '';
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} and ${names[1]}`;
return `${names[0]}, ${names[1]} and et. al`;
}
// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5}
async function buildCertNo(userId) {
const count = await Certificate.count({ where: { user_id: userId } });
const seq = String(count + 1).padStart(5, '0');
const uid = String(userId).padStart(6, '0');
const now = new Date();
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
return `${YYYYMM}-${uid}-${seq}`;
}
// ref_no format: PP-YYYYMM-{globalSeq:5} (unique across all certs)
async function buildRefNo() {
const count = await Certificate.count();
const seq = String(count + 1).padStart(5, '0');
const now = new Date();
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
return `PP-${YYYYMM}-${seq}`;
}
/**
* Resolves (or creates) the Certificate row for a user/course. Idempotent —
* safe to call from both the cron job and the on-demand download endpoint.
* Returns null if the course has no assessment or the user hasn't passed it.
*/
async function ensureCertificateRecord({ userId, courseId }) {
const course = await Course.findOne({
where: { course_id: courseId },
attributes: ['course_id', 'title', 'duration_seconds'],
include: [
{ model: CourseAssessment, as: 'assessment', attributes: ['assessment_id'], required: false },
{
model: CourseInstructor,
as: 'instructors',
attributes: ['display_name', 'order_index'],
required: false,
order: [['order_index', 'ASC']],
},
],
});
if (!course || !course.assessment) return null;
const passedAttempt = await QuizAttempt.findOne({
where: {
user_id: userId,
assessment_id: course.assessment.assessment_id,
passed: true,
},
order: [['createdAt', 'DESC']],
attributes: ['score', 'createdAt'],
});
if (!passedAttempt) return null;
const liveInstructors = formatInstructors(course.instructors ?? []);
// CockroachDB does not support findOrCreate (uses temp PL/pgSQL functions).
let cert = await Certificate.findOne({ where: { user_id: userId, course_id: courseId } });
if (!cert) {
cert = await Certificate.create({
user_id: userId,
course_id: courseId,
cert_no: await buildCertNo(userId),
ref_no: await buildRefNo(),
instructors: liveInstructors,
score: passedAttempt.score ?? null,
length_str: formatDuration(course.duration_seconds),
issued_at: passedAttempt.createdAt,
});
} else if (liveInstructors !== (cert.instructors ?? '')) {
await cert.update({ instructors: liveInstructors });
}
return cert;
}
module.exports = { ensureCertificateRecord, formatInstructors, buildCertNo, buildRefNo };
+33 -10
View File
@@ -2,18 +2,22 @@
* File Name: email.service.js * File Name: email.service.js
* Type of Program: Service * Type of Program: Service
* Description: Nodemailer-based email service. * Description: Nodemailer-based email service.
* Provides: * Subject + body per type are loaded from the email_templates
* - sendOTPEmail() → sends a 6-digit OTP verification email * table (admin-editable, see controllers/admin/email_templates.controller.js).
* - sendWelcomeEmail() → sent after successful email verification * The outer layout (header/footer/signature) below is fixed in
* Author: rgrgogu * code and is NOT admin-editable — only the body content is.
* Author: rgrgogu, Kenneth Obsequio (@lash0000)
* Date Created: Oct. 6, 2025 * Date Created: Oct. 6, 2025
* Date Modified: Jul. 3, 2026 — templates moved from data/email_body.data.js into the DB
*********************************************************************************************************************************************************************** ***********************************************************************************************************************************************************************
* HOW TO USE: * HOW TO USE:
* const emailService = require('../services/email.service'); * const emailService = require('../services/email.service');
* await emailService.sendOTPEmail(user.email, otp); * await emailService.sendEmail({ to: user.email, type: 'OTP', data: { otp } });
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
const { emailTemplates } = require('../data/email_body.data') const mdl_EmailTemplate = require('../models/email_templates/email_templates.mdl');
const { enrichEmailData } = require('../data/email_template_enrichers.data');
const { renderTemplate } = require('../utils/renderTemplate.util');
const port = Number(process.env.SMTP_PORT); const port = Number(process.env.SMTP_PORT);
@@ -31,15 +35,34 @@ const transporter = nodemailer.createTransport({
}, },
}); });
// Fixed layout — admins cannot change header/footer/signature via the CRUD,
// only the body content per template type.
const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;';
const wrap = (body) => `
<html>
<body style="${FONT} line-height: 1.6;">
${body.trim()}
<br><br>
<p style="margin: 0;">Regards,<br>Philproperties IT Team</p>
<p style="margin: 0; font-size: 12px; color: #555;">This is an automated message from STARR System. Please do not reply.</p>
</body>
</html>`.trim();
const sendEmail = async ({ to, type, data = {} }) => { const sendEmail = async ({ to, type, data = {} }) => {
try { try {
const templateFn = emailTemplates[type]; const template = await mdl_EmailTemplate.findOne({ where: { type } });
if (!template) {
if (!templateFn) {
throw new Error(`Email template "${type}" not found`); throw new Error(`Email template "${type}" not found`);
} }
// Draft content (or a template that's never been sent) never reaches
// real mail — only the live subject/html_body columns count as "published".
if (!template.subject || !template.html_body) {
throw new Error(`Email template "${type}" has no published (sent) version yet`);
}
const { subject, html } = templateFn(data); const enriched = enrichEmailData(type, data);
const subject = renderTemplate(template.subject, enriched);
const html = wrap(renderTemplate(template.html_body, enriched));
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
transporter.sendMail( transporter.sendMail(
+117
View File
@@ -0,0 +1,117 @@
/***********************************************************************************************************************************************************************
* File Name: mediaToken.service.js
* Type of Program: Service
* Description: Issues + caches short-lived JWT stream tokens (and presigned S3
* thumbnail URLs) for asset preview. Shared by:
* - controllers/admin/media.controller.js (POST /admin/media/token(s))
* - controllers/admin/assets.controller.js (embeds tokens directly
* into GET /admin/assets rows so pickers don't need a second
* round-trip just to render thumbnails)
*
* Author: Kenneth Obsequio (@lash0000)
***********************************************************************************************************************************************************************/
"use strict";
const jwt = require("jsonwebtoken");
const s3 = require("./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"];
// ─── In-memory token cache (no Redis yet) ──────────────────────────────────────
// Avoids re-signing a JWT / re-presigning the thumbnail S3 URL for an asset that
// already has a still-valid token. Keyed by (asset_id, ip) because the stream
// endpoint (/api/client/media/stream/:token) pins the token to the issuing
// request's IP — reusing a token minted for a different IP would get rejected.
// Single-process only; each app instance keeps its own cache.
const TOKEN_CACHE_MARGIN_SEC = 120; // re-mint a bit before actual expiry
const tokenCache = new Map(); // `${asset_id}:${ip}` -> { token, thumbnail_url, expiresAt }
function tokenCacheKey(assetId, ip) {
return `${assetId}:${ip}`;
}
function getCachedToken(assetId, ip) {
const key = tokenCacheKey(assetId, ip);
const entry = tokenCache.get(key);
if (!entry) return null;
if (Date.now() >= entry.expiresAt) {
tokenCache.delete(key);
return null;
}
return entry;
}
function setCachedToken(assetId, ip, token, thumbnail_url) {
tokenCache.set(tokenCacheKey(assetId, ip), {
token,
thumbnail_url,
expiresAt: Date.now() + (TOKEN_TTL_SEC - TOKEN_CACHE_MARGIN_SEC) * 1000,
});
}
// Collapses IPv4-mapped IPv6 ("::ffff:127.0.0.1") and IPv6 loopback ("::1")
// down to a single canonical form. Without this, a token minted off one
// "localhost" connection (IPv4) fails IP-pin verification on a sibling
// request that happened to land on the other stack (IPv6) — browsers race
// both when resolving "localhost", so mint and stream requests can land on
// different stacks even from the same client.
function normalizeIp(ip) {
if (ip === "::1") return "127.0.0.1";
if (ip.startsWith("::ffff:")) return ip.slice(7);
return ip;
}
function resolveIp(req) {
const forwarded = req.headers["x-forwarded-for"];
const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown");
return normalizeIp(raw);
}
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 }
);
}
// ─── issueForAsset ─────────────────────────────────────────────────────────────
//
// Returns { token, thumbnail_url } for an S3 asset, minting + caching on first
// call and serving from tokenCache on subsequent calls within the TTL margin.
// `asset` needs: asset_id, storage_key, file_type, mime_type, thumbnail_storage_key.
//
async function issueForAsset(asset, userId, ip) {
const cached = getCachedToken(asset.asset_id, ip);
if (cached) return { token: cached.token, thumbnail_url: cached.thumbnail_url };
const token = signToken(asset, userId, ip);
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
}
}
setCachedToken(asset.asset_id, ip, token, thumbnail_url);
return { token, thumbnail_url };
}
module.exports = {
TOKEN_TTL_SEC,
SUPPORTED_TYPES,
resolveIp,
issueForAsset,
};
-75
View File
@@ -1,75 +0,0 @@
/***********************************************************************************************************************************************************************
* File Name: paypal.service.js
* Type of Program: Service
* Description: PayPal Orders API helpers — create order, capture order.
* Uses client-side JS SDK button → server capture flow.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 6, 2026
***********************************************************************************************************************************************************************/
const axios = require('axios');
const BASE_URL = process.env.PAYPAL_ENV === 'live'
? 'https://api-m.paypal.com'
: 'https://api-m.sandbox.paypal.com';
const getAccessToken = async () => {
const { data } = await axios.post(
`${BASE_URL}/v1/oauth2/token`,
'grant_type=client_credentials',
{
auth: {
username: process.env.PAYPAL_CLIENT_ID,
password: process.env.PAYPAL_CLIENT_SECRET,
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}
);
return data.access_token;
};
exports.createOrder = async ({ amount, currency = 'USD', referenceId, returnUrl, cancelUrl }) => {
const token = await getAccessToken();
const { data } = await axios.post(
`${BASE_URL}/v2/checkout/orders`,
{
intent: 'CAPTURE',
purchase_units: [{
reference_id: referenceId,
amount: { currency_code: currency, value: String(amount) },
}],
application_context: {
return_url: returnUrl ?? `${process.env.FRONTEND_URL}/plans/checkout`,
cancel_url: cancelUrl ?? `${process.env.FRONTEND_URL}/plans/checkout?cancelled=true`,
brand_name: 'Philproperties',
user_action: 'PAY_NOW',
},
},
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
);
return data;
};
exports.captureOrder = async (orderId) => {
const token = await getAccessToken();
const { data } = await axios.post(
`${BASE_URL}/v2/checkout/orders/${orderId}/capture`,
{},
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
);
return data; // { id, status, purchase_units, payer }
};
exports.refundCapture = async (captureId, amount, currency = 'USD') => {
const token = await getAccessToken();
const { data } = await axios.post(
`${BASE_URL}/v2/payments/captures/${captureId}/refund`,
{
amount: {
value: String(amount),
currency_code: currency,
},
},
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
);
return data; // { id, status, amount, ... }
};
@@ -0,0 +1,138 @@
/***********************************************************************************************************************************************************************
* File Name: task_reading_progress_sync.service.js
* Type of Program: Service
* Description: Backfills task_progress for read_* task requirements from course_reading_progress.
*
* This covers the case where a user already completed reading a course/unit/lesson
* before a task requiring that item was created or assigned.
***********************************************************************************************************************************************************************/
'use strict';
const { Op } = require('sequelize');
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
const { TaskProgress } = require('../models/task/task_progress.mdl');
const READ_TYPE_TO_PROGRESS_TYPE = {
read_course: 'course',
read_unit: 'unit',
read_lesson: 'lesson',
};
const READ_REQUIREMENT_TYPES = Object.keys(READ_TYPE_TO_PROGRESS_TYPE);
function readAttr(row, attr) {
if (!row) return undefined;
if (typeof row.get === 'function') return row.get(attr);
return row[attr];
}
function normalizeRequirement(row) {
const type = readAttr(row, 'type');
if (!READ_REQUIREMENT_TYPES.includes(type)) return null;
const referenceId = readAttr(row, 'reference_id');
if (!referenceId) return null;
return {
task_id: readAttr(row, 'task_id'),
requirement_id: readAttr(row, 'requirement_id'),
reference_id: referenceId,
type,
};
}
async function hydrateReadTaskProgress(userId, requirements = [], options = {}) {
const readRequirements = requirements
.map(normalizeRequirement)
.filter((req) => req && req.task_id && req.requirement_id);
if (!readRequirements.length) return [];
const referencesByProgressType = readRequirements.reduce((acc, req) => {
const progressType = READ_TYPE_TO_PROGRESS_TYPE[req.type];
if (!acc[progressType]) acc[progressType] = new Set();
acc[progressType].add(req.reference_id);
return acc;
}, {});
const where = {
user_id: userId,
status: 'completed',
[Op.or]: Object.entries(referencesByProgressType).map(([type, references]) => ({
type,
reference_id: { [Op.in]: [...references] },
})),
};
const completedReadingRows = await CourseReadingProgress.findAll({
where,
attributes: ['type', 'reference_id', 'completed_at'],
transaction: options.transaction,
});
if (!completedReadingRows.length) return [];
const completedReading = new Map(
completedReadingRows.map((row) => [
`${readAttr(row, 'type')}:${readAttr(row, 'reference_id')}`,
readAttr(row, 'completed_at'),
])
);
const now = new Date();
const rowsToUpsert = readRequirements.filter((req) =>
completedReading.has(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`)
);
if (!rowsToUpsert.length) return [];
const existingProgressRows = await TaskProgress.findAll({
where: {
user_id: userId,
completed: true,
requirement_id: { [Op.in]: rowsToUpsert.map((req) => req.requirement_id) },
reference_id: { [Op.in]: rowsToUpsert.map((req) => req.reference_id) },
},
attributes: ['requirement_id', 'reference_id'],
transaction: options.transaction,
});
const existingProgress = new Set(
existingProgressRows.map((row) =>
`${readAttr(row, 'requirement_id')}:${readAttr(row, 'reference_id')}`
)
);
const missingRows = rowsToUpsert.filter((req) =>
!existingProgress.has(`${req.requirement_id}:${req.reference_id}`)
);
if (!missingRows.length) return [];
await Promise.all(missingRows.map((req) =>
TaskProgress.upsert(
{
task_id: req.task_id,
requirement_id: req.requirement_id,
user_id: userId,
reference_id: req.reference_id,
type: req.type,
completed: true,
completed_at: completedReading.get(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`) ?? now,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: options.transaction,
}
)
));
return missingRows;
}
module.exports = {
hydrateReadTaskProgress,
READ_REQUIREMENT_TYPES,
};
+201
View File
@@ -0,0 +1,201 @@
'use strict';
// ── Integration test: paypal.provider.js against a mocked HTTP boundary ───────
// Only axios (the true external boundary) is mocked — everything else in the
// provider (token exchange, URL selection, request shaping) runs for real.
// This is what tells us the provider is ready to go live: wrong env vars, a
// broken sandbox/live switch, or a malformed request body will fail here
// exactly like it would against the real PayPal API.
jest.mock('axios');
const BASE_ENV = {
PAYPAL_CLIENT_ID: 'test-client-id',
PAYPAL_CLIENT_SECRET: 'test-client-secret',
PAYPAL_BRAND_NAME: 'STARR',
FRONTEND_URL: 'https://app.new-starr.test',
};
// BASE_URL is computed once at module load time from PAYPAL_ENV, so every
// test that cares about sandbox/live must reset the module registry first.
// axios must be re-required from the same fresh registry, otherwise its mock
// calls land on a different automock instance than the one the provider uses.
function loadProvider(envOverrides = {}) {
jest.resetModules();
Object.assign(process.env, BASE_ENV, envOverrides);
const axios = require('axios');
const provider = require('../../providers/paypal.provider');
return { provider, axios };
}
function mockAccessToken(axios, token = 'test-access-token') {
axios.post.mockImplementationOnce((url) => {
expect(url).toMatch(/\/v1\/oauth2\/token$/);
return Promise.resolve({ data: { access_token: token } });
});
}
beforeEach(() => {
delete process.env.PAYPAL_ENV;
});
// ── Config: sandbox vs live host selection ────────────────────────────────────
describe('environment / config wiring', () => {
test('defaults to the sandbox host when PAYPAL_ENV is unset', async () => {
const { provider, axios } = loadProvider({});
mockAccessToken(axios);
axios.post.mockResolvedValueOnce({ data: {} });
await provider.createOrder({ amount: 10, referenceId: 'ref-1' });
expect(axios.post.mock.calls[0][0]).toBe('https://api-m.sandbox.paypal.com/v1/oauth2/token');
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders');
});
test('switches to the live host when PAYPAL_ENV=live', async () => {
const { provider, axios } = loadProvider({ PAYPAL_ENV: 'live' });
mockAccessToken(axios);
axios.post.mockResolvedValueOnce({ data: {} });
await provider.createOrder({ amount: 10, referenceId: 'ref-1' });
expect(axios.post.mock.calls[0][0]).toBe('https://api-m.paypal.com/v1/oauth2/token');
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.paypal.com/v2/checkout/orders');
});
test('token request authenticates with PAYPAL_CLIENT_ID / PAYPAL_CLIENT_SECRET via HTTP Basic auth', async () => {
const { provider, axios } = loadProvider({});
mockAccessToken(axios);
axios.post.mockResolvedValueOnce({ data: {} });
await provider.createOrder({ amount: 10, referenceId: 'ref-1' });
const [, , config] = axios.post.mock.calls[0];
expect(config.auth).toEqual({ username: 'test-client-id', password: 'test-client-secret' });
expect(config.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
});
test('a rejected token exchange (bad credentials) surfaces as a rejected promise, not a silent failure', async () => {
const { provider, axios } = loadProvider({});
axios.post.mockRejectedValueOnce(new Error('401 invalid_client'));
await expect(provider.createOrder({ amount: 10, referenceId: 'ref-1' }))
.rejects.toThrow('401 invalid_client');
});
});
// ── createOrder ────────────────────────────────────────────────────────────────
describe('createOrder()', () => {
test('sends a CAPTURE intent order with the reference id, amount and currency', async () => {
const { provider, axios } = loadProvider({});
mockAccessToken(axios, 'tok-abc');
axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'CREATED' } });
const result = await provider.createOrder({
amount: 19.99,
currency: 'PHP',
referenceId: 'plan-42',
});
const [url, body, config] = axios.post.mock.calls[1];
expect(url).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders');
expect(body.intent).toBe('CAPTURE');
expect(body.purchase_units[0]).toMatchObject({
reference_id: 'plan-42',
amount: { currency_code: 'PHP', value: '19.99' },
});
expect(config.headers.Authorization).toBe('Bearer tok-abc');
expect(result).toEqual({ id: 'ORDER-1', status: 'CREATED' });
});
test('defaults currency to USD when not provided', async () => {
const { provider, axios } = loadProvider({});
mockAccessToken(axios);
axios.post.mockResolvedValueOnce({ data: {} });
await provider.createOrder({ amount: 5, referenceId: 'ref-1' });
const [, body] = axios.post.mock.calls[1];
expect(body.purchase_units[0].amount.currency_code).toBe('USD');
});
test('falls back to FRONTEND_URL for return/cancel urls when not provided', async () => {
const { provider, axios } = loadProvider({});
mockAccessToken(axios);
axios.post.mockResolvedValueOnce({ data: {} });
await provider.createOrder({ amount: 5, referenceId: 'ref-1' });
const [, body] = axios.post.mock.calls[1];
expect(body.application_context.return_url).toBe('https://app.new-starr.test/plans/checkout');
expect(body.application_context.cancel_url).toBe('https://app.new-starr.test/plans/checkout?cancelled=true');
});
test('honors explicit return/cancel urls when provided', async () => {
const { provider, axios } = loadProvider({});
mockAccessToken(axios);
axios.post.mockResolvedValueOnce({ data: {} });
await provider.createOrder({
amount: 5, referenceId: 'ref-1',
returnUrl: 'https://custom.test/ok',
cancelUrl: 'https://custom.test/cancel',
});
const [, body] = axios.post.mock.calls[1];
expect(body.application_context.return_url).toBe('https://custom.test/ok');
expect(body.application_context.cancel_url).toBe('https://custom.test/cancel');
});
});
// ── captureOrder ───────────────────────────────────────────────────────────────
describe('captureOrder()', () => {
test('posts to the order capture endpoint with a bearer token and returns the capture payload', async () => {
const { provider, axios } = loadProvider({});
mockAccessToken(axios, 'tok-xyz');
axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'COMPLETED' } });
const result = await provider.captureOrder('ORDER-1');
const [url, body, config] = axios.post.mock.calls[1];
expect(url).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders/ORDER-1/capture');
expect(body).toEqual({});
expect(config.headers.Authorization).toBe('Bearer tok-xyz');
expect(result).toEqual({ id: 'ORDER-1', status: 'COMPLETED' });
});
});
// ── refundCapture ──────────────────────────────────────────────────────────────
describe('refundCapture()', () => {
test('posts a refund with the given amount and currency', async () => {
const { provider, axios } = loadProvider({});
mockAccessToken(axios);
axios.post.mockResolvedValueOnce({ data: { id: 'REFUND-1', status: 'COMPLETED' } });
const result = await provider.refundCapture('CAPTURE-1', 9.5, 'PHP');
const [url, body] = axios.post.mock.calls[1];
expect(url).toBe('https://api-m.sandbox.paypal.com/v2/payments/captures/CAPTURE-1/refund');
expect(body).toEqual({ amount: { value: '9.5', currency_code: 'PHP' } });
expect(result).toEqual({ id: 'REFUND-1', status: 'COMPLETED' });
});
test('defaults currency to USD when not provided', async () => {
const { provider, axios } = loadProvider({});
mockAccessToken(axios);
axios.post.mockResolvedValueOnce({ data: {} });
await provider.refundCapture('CAPTURE-1', 9.5);
const [, body] = axios.post.mock.calls[1];
expect(body.amount.currency_code).toBe('USD');
});
});
+51
View File
@@ -0,0 +1,51 @@
'use strict';
// ── Provider contract test ────────────────────────────────────────────────────
// This suite is the "gate" for onboarding new payment providers. It does not
// hardcode "paypal" as the only case — it walks registry.list() and asserts
// every registered provider satisfies the shape payment.service.js relies on.
// Drop a second provider into providers/registry.js and this file validates it
// for free, with zero new test code required.
const registry = require('../../providers/registry');
const REQUIRED_METHODS = ['createOrder', 'captureOrder', 'refundCapture'];
describe('payment provider registry', () => {
test('lists at least one provider', () => {
expect(registry.list().length).toBeGreaterThan(0);
});
test('currently registers paypal', () => {
expect(registry.list()).toContain('paypal');
});
test('get() returns the provider module for a known name', () => {
const provider = registry.get('paypal');
expect(provider).toBeDefined();
});
test('get() throws a descriptive error for an unknown provider', () => {
expect(() => registry.get('stripe')).toThrow(/Unknown payment provider: "stripe"/);
});
test('unknown-provider error lists the available providers so misconfiguration is easy to diagnose', () => {
try {
registry.get('does-not-exist');
throw new Error('expected registry.get to throw');
} catch (err) {
registry.list().forEach((name) => {
expect(err.message).toContain(name);
});
}
});
describe.each(registry.list())('provider contract: %s', (name) => {
const provider = registry.get(name);
test.each(REQUIRED_METHODS)('exposes %s as a function', (method) => {
expect(typeof provider[method]).toBe('function');
});
});
});
+159
View File
@@ -0,0 +1,159 @@
'use strict';
// ── Integration test: payment.service.js → providers/registry → paypal.provider ──
// Only the DB models and axios (the two real external boundaries) are mocked.
// registry.js and paypal.provider.js run unmodified, so this proves the full
// orchestration chain (service → registry lookup → provider → HTTP) is wired
// correctly end to end, not just that each piece works in isolation.
process.env.PAYPAL_CLIENT_ID = 'test-client-id';
process.env.PAYPAL_CLIENT_SECRET = 'test-client-secret';
process.env.FRONTEND_URL = 'https://app.new-starr.test';
const axios = require('axios');
jest.mock('axios');
jest.mock('../../models/tiers/payment_policies.mdl', () => ({ findOne: jest.fn() }));
jest.mock('../../models/tiers/payments.mdl', () => ({ count: jest.fn() }));
const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl');
const paymentService = require('../../services/payment.service');
beforeEach(() => jest.clearAllMocks());
function mockAccessToken(token = 'tok-abc') {
axios.post.mockImplementationOnce((url) => {
expect(url).toMatch(/\/v1\/oauth2\/token$/);
return Promise.resolve({ data: { access_token: token } });
});
}
// ── Provider delegation ─────────────────────────────────────────────────────────
describe('provider delegation (registry → paypal.provider → axios)', () => {
test('createOrder("paypal", ...) reaches PayPal\'s create-order endpoint', async () => {
mockAccessToken();
axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'CREATED' } });
const result = await paymentService.createOrder('paypal', { amount: 25, referenceId: 'plan-1' });
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders');
expect(result).toEqual({ id: 'ORDER-1', status: 'CREATED' });
});
test('captureOrder("paypal", ...) reaches PayPal\'s capture endpoint', async () => {
mockAccessToken();
axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'COMPLETED' } });
const result = await paymentService.captureOrder('paypal', 'ORDER-1');
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders/ORDER-1/capture');
expect(result).toEqual({ id: 'ORDER-1', status: 'COMPLETED' });
});
test('refundCapture("paypal", ...) reaches PayPal\'s refund endpoint', async () => {
mockAccessToken();
axios.post.mockResolvedValueOnce({ data: { id: 'REFUND-1', status: 'COMPLETED' } });
const result = await paymentService.refundCapture('paypal', 'CAPTURE-1', 10, 'USD');
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/payments/captures/CAPTURE-1/refund');
expect(result).toEqual({ id: 'REFUND-1', status: 'COMPLETED' });
});
test('an unconfigured/unknown provider name fails fast with a descriptive error instead of hitting axios', () => {
expect(() => paymentService.createOrder('stripe', { amount: 25, referenceId: 'plan-1' }))
.toThrow(/Unknown payment provider: "stripe"/);
expect(axios.post).not.toHaveBeenCalled();
});
});
// ── Refund policy ────────────────────────────────────────────────────────────────
describe('refund policy', () => {
test('falls back to the 5-minute default window when no policy is configured', () => {
expect(paymentService.getRefundWindowMs(null)).toBe(5 * 60_000);
expect(paymentService.isRefundAllowed(null)).toBe(true);
});
test('honors a custom policy window and unit', () => {
const policy = { refund_policy: { allowed: false, window_value: 2, window_unit: 'hours' } };
expect(paymentService.getRefundWindowMs(policy)).toBe(2 * 3_600_000);
expect(paymentService.isRefundAllowed(policy)).toBe(false);
});
test('getPolicyForPlan() looks up the policy by plan_id', async () => {
mdl_PaymentPolicies.findOne.mockResolvedValue({ plan_id: 7 });
const policy = await paymentService.getPolicyForPlan(7);
expect(mdl_PaymentPolicies.findOne).toHaveBeenCalledWith({ where: { plan_id: 7 } });
expect(policy).toEqual({ plan_id: 7 });
});
});
// ── Promo evaluation ───────────────────────────────────────────────────────────
describe('evaluatePromo()', () => {
const plan = { plan_id: 1, price: 100 };
test('rejects when no code is provided', async () => {
const result = await paymentService.evaluatePromo({}, plan, '');
expect(result).toEqual({ valid: false, reason: 'No promo code provided.' });
});
test('rejects an unknown code', async () => {
const policy = { promo_rules: [] };
const result = await paymentService.evaluatePromo(policy, plan, 'BOGUS');
expect(result.valid).toBe(false);
expect(result.reason).toBe('Invalid promo code.');
});
test('applies a flat discount', async () => {
const policy = { promo_rules: [{ code: 'FLAT10', type: 'flat', value: 10 }] };
mdl_Payments.count.mockResolvedValue(0);
const result = await paymentService.evaluatePromo(policy, plan, 'flat10');
expect(result).toMatchObject({ valid: true, code: 'FLAT10', discount: 10 });
});
test('applies a percent discount capped by max_discount', async () => {
const policy = { promo_rules: [{ code: 'PCT50', type: 'percent', value: 50, max_discount: 30 }] };
mdl_Payments.count.mockResolvedValue(0);
const result = await paymentService.evaluatePromo(policy, plan, 'PCT50');
expect(result).toMatchObject({ valid: true, discount: 30 });
});
test('rejects an expired code', async () => {
const policy = { promo_rules: [{ code: 'OLD', type: 'flat', value: 5, expires_at: '2000-01-01' }] };
const result = await paymentService.evaluatePromo(policy, plan, 'OLD');
expect(result).toMatchObject({ valid: false, reason: 'Promo code has expired.' });
});
test('rejects once max_uses has been reached', async () => {
const policy = { promo_rules: [{ code: 'LIMITED', type: 'flat', value: 5, max_uses: 2 }] };
mdl_Payments.count.mockResolvedValue(2);
const result = await paymentService.evaluatePromo(policy, plan, 'LIMITED');
expect(result).toMatchObject({ valid: false, reason: 'Promo code has reached its usage limit.' });
});
test('rejects when the subtotal is below min_amount', async () => {
const policy = { promo_rules: [{ code: 'BIGSPEND', type: 'flat', value: 5, min_amount: 200 }] };
mdl_Payments.count.mockResolvedValue(0);
const result = await paymentService.evaluatePromo(policy, plan, 'BIGSPEND');
expect(result.valid).toBe(false);
expect(result.reason).toMatch(/minimum purchase/);
});
test('evaluates against effectivePrice (localized currency) instead of plan.price when provided', async () => {
const policy = { promo_rules: [{ code: 'PCT10', type: 'percent', value: 10 }] };
mdl_Payments.count.mockResolvedValue(0);
const result = await paymentService.evaluatePromo(policy, plan, 'PCT10', 50);
expect(result.discount).toBe(5);
});
});
+153
View File
@@ -0,0 +1,153 @@
/***********************************************************************************************************************************************************************
* File Name: audienceResolver.util.js
* Type of Program: Utility
* Description: Shared "who does this target reach" resolution for anything
* broadcast-shaped (notification broadcasts, email broadcasts).
* Extracted out of controllers/admin/notificationBroadcasts.controller.js
* so both features resolve task_list/course/tier_plan targeting
* identically instead of drifting apart.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 3, 2026
***********************************************************************************************************************************************************************/
const { Op, QueryTypes } = require('sequelize');
const sequelize = require('../config/db.config');
const mdl_Users = require('../models/users/users.mdl');
const { TaskList } = require('../models/task/task.mdl');
const { Course } = require('../models/courses/courses.mdl');
const mdl_TierCategories = require('../models/tiers/tier_categories.mdl');
const mdl_UserTiers = require('../models/tiers/user_tiers.mdl');
const mdl_TierPlans = require('../models/tiers/tier_plans.mdl');
const mdl_Product = require('../models/courses/products.mdl');
const mdl_CoursePurchase = require('../models/courses/course_purchases.mdl');
const ALLOWED_TARGET_TYPES = ["admin", "user", "both", "task_list", "course", "tier_plan"];
const SCOPED_TARGET_TYPES = ["task_list", "course", "tier_plan"];
async function validateTargetId(target_type, target_id) {
if (target_type === "task_list") {
const row = await TaskList.findOne({ where: { task_list_id: target_id, deletedAt: null } });
if (!row) { const err = new Error("Selected task list was not found."); err.status = 400; throw err; }
} else if (target_type === "course") {
const row = await Course.findOne({ where: { uuid: target_id, deletedAt: null } });
if (!row) { const err = new Error("Selected course was not found."); err.status = 400; throw err; }
} else if (target_type === "tier_plan") {
const row = await mdl_TierPlans.findOne({ where: { plan_id: target_id, deletedAt: null } });
if (!row) { const err = new Error("Selected tier plan was not found."); err.status = 400; throw err; }
}
}
// Rank-0 (free) courses resolve like target_type: 'user' — everyone qualifies.
async function resolveCourseUserIds(courseUuid) {
const course = await Course.findOne({ where: { uuid: courseUuid, deletedAt: null }, attributes: ['course_id', 'subscription'] });
if (!course) return [];
// rank is BIGINT on CockroachDB — Sequelize returns it as a string, so normalize to Number.
const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
const rankMap = Object.fromEntries(categories.map((c) => [c.slug, Number(c.rank)]));
const courseRank = rankMap[course.subscription] ?? Infinity;
const userIds = new Set();
if (courseRank === 0) {
const users = await mdl_Users.findAll({ attributes: ['user_id'], where: { acc_type: 'user', deletedAt: null }, raw: true });
users.forEach((u) => userIds.add(String(u.user_id)));
return [...userIds];
}
const qualifyingSlugs = Object.entries(rankMap).filter(([, rank]) => rank >= courseRank).map(([slug]) => slug);
if (qualifyingSlugs.length) {
const holders = await mdl_UserTiers.findAll({
attributes: ['user_id'],
where: { status: 'active', tier: { [Op.in]: qualifyingSlugs } },
raw: true,
});
holders.forEach((h) => userIds.add(String(h.user_id)));
}
const product = await mdl_Product.findOne({ where: { course_id: course.course_id } });
if (product) {
const purchasers = await mdl_CoursePurchase.findAll({
attributes: ['user_id'],
where: {
product_id: product.id,
status: 'completed',
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
raw: true,
});
purchasers.forEach((p) => userIds.add(String(p.user_id)));
}
return [...userIds];
}
async function resolveTaskListUserIds(taskListId) {
const rows = await sequelize.query(
`SELECT DISTINCT ugm.user_id
FROM task_list_groups tlg
JOIN user_group_members ugm ON ugm.group_id = tlg.group_id AND ugm."deletedAt" IS NULL
WHERE tlg.task_list_id = :taskListId`,
{ replacements: { taskListId }, type: QueryTypes.SELECT }
);
return rows.map((r) => String(r.user_id));
}
// user_id → group_id, for deep-linking task_list broadcasts to /group/:groupId/view/:taskListId.
// First matching group wins if a user belongs to more than one group tied to the task list.
async function resolveTaskListUserGroups(taskListId) {
const rows = await sequelize.query(
`SELECT ugm.user_id, tlg.group_id
FROM task_list_groups tlg
JOIN user_group_members ugm ON ugm.group_id = tlg.group_id AND ugm."deletedAt" IS NULL
WHERE tlg.task_list_id = :taskListId`,
{ replacements: { taskListId }, type: QueryTypes.SELECT }
);
const map = {};
for (const r of rows) {
const uid = String(r.user_id);
if (!(uid in map)) map[uid] = r.group_id;
}
return map;
}
async function resolveTierPlanUserIds(planId) {
const holders = await mdl_UserTiers.findAll({
attributes: ['user_id'],
where: { plan_id: planId, status: 'active' },
raw: true,
});
return holders.map((h) => String(h.user_id));
}
// Dispatcher for the 3 "scoped" target types only — 'admin'/'user'/'both' mean
// different things to different callers (e.g. notification broadcasts post to
// the shared admin bell feed for 'admin'; email broadcasts email every admin/
// staff user instead), so those stay caller-specific rather than living here.
async function resolveTargetUserIds(target_type, target_id) {
if (target_type === 'task_list') return resolveTaskListUserIds(target_id);
if (target_type === 'course') return resolveCourseUserIds(target_id);
if (target_type === 'tier_plan') return resolveTierPlanUserIds(target_id);
return [];
}
async function resolveAllUserIds({ transaction } = {}) {
const users = await mdl_Users.findAll({
attributes: ['user_id'],
where: { acc_type: 'user', deletedAt: null },
raw: true,
transaction,
});
return users.map((u) => String(u.user_id));
}
module.exports = {
ALLOWED_TARGET_TYPES,
SCOPED_TARGET_TYPES,
validateTargetId,
resolveCourseUserIds,
resolveTaskListUserIds,
resolveTaskListUserGroups,
resolveTierPlanUserIds,
resolveTargetUserIds,
resolveAllUserIds,
};
-171
View File
@@ -1,171 +0,0 @@
/***********************************************************************************************************************************************************************
* File Name: currency.util.js
* Type of Program: Utility
* Description: Currency formatting and resolution helpers for backend use (emails, receipts, notifications).
*
* All format functions accept an optional options object: { locale }
* locale — BCP 47 tag, defaults to 'en-US'
*
* USD is the platform's base/canonical currency. Plans may carry localized price
* overrides (plan_prices table). resolvePrice() applies the COALESCE logic:
* localized override wins → falls back to plan's base price + currency.
***********************************************************************************************************************************************************************/
'use strict';
// ─── Supported currencies ─────────────────────────────────────────────────────
const SUPPORTED_CURRENCIES = [
{ code: 'USD', name: 'US Dollar', symbol: '$' },
{ code: 'EUR', name: 'Euro', symbol: '€' },
{ code: 'GBP', name: 'British Pound', symbol: '£' },
{ code: 'CNY', name: 'Chinese Yuan', symbol: '¥' },
{ code: 'JPY', name: 'Japanese Yen', symbol: '¥' },
{ code: 'PHP', name: 'Philippine Peso', symbol: '₱' },
{ code: 'KRW', name: 'South Korean Won', symbol: '₩' },
{ code: 'AUD', name: 'Australian Dollar', symbol: 'A$' },
{ code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' },
{ code: 'SGD', name: 'Singapore Dollar', symbol: 'S$' },
{ code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$'},
{ code: 'INR', name: 'Indian Rupee', symbol: '₹' },
{ code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM' },
{ code: 'THB', name: 'Thai Baht', symbol: '฿' },
{ code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp' },
{ code: 'TWD', name: 'Taiwan Dollar', symbol: 'NT$'},
{ code: 'VND', name: 'Vietnamese Dong', symbol: '₫' },
];
const SUPPORTED_CURRENCY_CODES = new Set(SUPPORTED_CURRENCIES.map((c) => c.code));
function isSupported(code) {
return SUPPORTED_CURRENCY_CODES.has(code?.toUpperCase());
}
// ─── Formatting ───────────────────────────────────────────────────────────────
/** "¥1,299.00" / "$9.99" */
function fmtCurrency(amount, currency = 'USD', { locale = 'en-US' } = {}) {
if (amount === null || amount === undefined) return '—';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency ?? 'USD',
minimumFractionDigits: 2,
}).format(Number(amount));
}
// ─── Price resolution ─────────────────────────────────────────────────────────
/**
* Returns the effective { price, currency } for a plan given a user's preferred currency.
* plan.prices must be eager-loaded (as: 'prices') for the override to be considered.
* Falls back to plan.price + plan.currency when no override exists.
*/
function resolvePrice(plan, preferredCurrency) {
if (!preferredCurrency || preferredCurrency === plan.currency)
return { price: Number(plan.price), currency: plan.currency };
const override = (plan.prices ?? []).find((p) => p.currency === preferredCurrency);
if (override) return { price: Number(override.price), currency: override.currency };
return { price: Number(plan.price), currency: plan.currency };
}
// ─── Exchange rate fetching ───────────────────────────────────────────────────
// Uses frankfurter.app (ECB-backed, no API key, free).
// In-process cache with 1-hour TTL avoids hammering the API on every save.
const _rateCache = new Map();
async function fetchExchangeRate(from, to) {
if (from === to) return 1;
const key = `${from}:${to}`;
const now = Date.now();
const cached = _rateCache.get(key);
if (cached && cached.expiresAt > now) return cached.rate;
try {
const res = await fetch(
`https://api.frankfurter.app/latest?from=${from}&to=${to}`,
{ signal: AbortSignal.timeout(4000) },
);
if (!res.ok) return null;
const json = await res.json();
const rate = json?.rates?.[to];
if (!rate) return null;
_rateCache.set(key, { rate, expiresAt: now + 60 * 60 * 1000 }); // 1 h TTL
return rate;
} catch {
return null;
}
}
// ─── Localized price validation ───────────────────────────────────────────────
// Three zones relative to the market-rate conversion of the base price:
//
// pass → 85 % – 150 % of expected (green, saves normally)
// warn → 70 % – 85 % or 150 % – 300 % (saves with caution message)
// block → < 70 % or > 300 % (rejected — too far from market rate)
//
// If the exchange-rate API is unavailable the check is skipped (returns 'pass').
const PRICE_ZONES = {
LOWER_HARD: 0.70,
LOWER_WARN: 0.85,
UPPER_WARN: 1.50,
UPPER_HARD: 3.00,
};
async function validateLocalizedPrice(basePrice, baseCurrency, localizedPrice, targetCurrency) {
const rate = await fetchExchangeRate(baseCurrency, targetCurrency);
if (!rate) return { zone: 'pass', skipped: true };
const expected = Number(basePrice) * rate;
const entered = Number(localizedPrice);
const { LOWER_HARD, LOWER_WARN, UPPER_WARN, UPPER_HARD } = PRICE_ZONES;
const hardMin = expected * LOWER_HARD;
const hardMax = expected * UPPER_HARD;
const warnMin = expected * LOWER_WARN;
const warnMax = expected * UPPER_WARN;
const fmt = (n) => n.toFixed(2);
const rateStr = `1 ${baseCurrency} = ${rate} ${targetCurrency}`;
if (entered < hardMin || entered > hardMax) {
return {
zone: 'block',
expected: fmt(expected),
hardMin: fmt(hardMin), hardMax: fmt(hardMax),
warnMin: fmt(warnMin), warnMax: fmt(warnMax),
message: `${fmt(entered)} ${targetCurrency} is too far from the current market rate (${rateStr}). ` +
`Acceptable range: ${fmt(hardMin)} – ${fmt(hardMax)} ${targetCurrency}.`,
};
}
if (entered < warnMin || entered > warnMax) {
return {
zone: 'warn',
expected: fmt(expected),
hardMin: fmt(hardMin), hardMax: fmt(hardMax),
warnMin: fmt(warnMin), warnMax: fmt(warnMax),
message: `${fmt(entered)} ${targetCurrency} is outside the suggested range (${rateStr}). ` +
`Suggested: ${fmt(warnMin)} – ${fmt(warnMax)} ${targetCurrency}. Saved with caution.`,
};
}
return {
zone: 'pass',
expected: fmt(expected),
hardMin: fmt(hardMin), hardMax: fmt(hardMax),
warnMin: fmt(warnMin), warnMax: fmt(warnMax),
};
}
module.exports = {
SUPPORTED_CURRENCIES,
isSupported,
fmtCurrency,
resolvePrice,
fetchExchangeRate,
validateLocalizedPrice,
PRICE_ZONES,
};
+21
View File
@@ -0,0 +1,21 @@
/***********************************************************************************************************************************************************************
* File Name: renderTemplate.util.js
* Type of Program: Utility
* Description: Plain-text {{placeholder}} substitution — no eval/Function, so
* admin-supplied HTML can never execute arbitrary JS. Unknown or
* missing keys resolve to an empty string rather than throwing.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 3, 2026
***********************************************************************************************************************************************************************
* HOW TO USE:
* const { renderTemplate } = require('../utils/renderTemplate.util');
* renderTemplate('Hi {{name}}', { name: 'Ken' }); // "Hi Ken"
***********************************************************************************************************************************************************************/
const renderTemplate = (str, data = {}) =>
String(str ?? '').replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => {
const val = data[key];
return (val === undefined || val === null) ? '' : String(val);
});
module.exports = { renderTemplate };