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 mdl_Assets = require("../../models/assets/assets.mdl");
const mdl_Users = require('../../models/users/users.mdl');
const mediaToken = require("../../services/mediaToken.service");
const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/advertisements/advertisements.attributes");
const { PLACEMENT_MAP, PLACEMENT_KEYS } = require("../../models/advertisements/advertisements.placements");
const { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util');
@@ -16,9 +18,32 @@ const { Op } = require('sequelize');
const notDeleted = { deletedAt: null };
const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"];
const ALLOWED_STATUSES = ["draft", "active", "scheduled", "expired", "archived"];
// Fields needed off the associated Asset to render a preview AND (for S3 assets)
// mint a stream token — storage_key is stripped again in attachImageStreamToken
// before the row is ever sent out.
const AD_IMAGE_ATTRIBUTES = ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"];
// ─── Media proxying ─────────────────────────────────────────────────────────
// Mirrors controllers/admin/assets.controller.js's redactS3Url/attachStreamTokens.
// Private (S3-backed) advertisement images must never expose a raw file_url to
// the browser — mint a short-lived stream token instead so the frontend resolves
// it through GET /api/client/media/stream/:token. Public/chibisafe images keep
// their direct file_url (no proxy needed).
async function attachImageStreamToken(image, req) {
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
return image;
}
const ip = mediaToken.resolveIp(req);
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
image.stream_token = token;
image.file_url = null;
image.thumbnail_url = null;
delete image.storage_key;
return image;
}
// ─── Status derivation ─────────────────────────────────────────────────────
// status is never trusted as manually-set truth — it's derived from is_active
// + start_date/end_date every time an advertisement is read or written.
@@ -53,13 +78,17 @@ function normalizeCtas(ctas) {
}
async function applyAdvertisementFields(advertisement, body) {
if (body.type !== undefined) {
if (!ALLOWED_TYPES.includes(body.type)) {
const err = new Error(`Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`);
// placement is the only settable "where" — type/format is always derived
// from the placement's registry entry, never accepted directly from the body.
if (body.placement !== undefined) {
const entry = PLACEMENT_MAP[body.placement];
if (!entry) {
const err = new Error(`Invalid placement. Must be one of: ${PLACEMENT_KEYS.join(", ")}`);
err.status = 400;
throw err;
}
advertisement.type = body.type;
advertisement.placement = body.placement;
advertisement.type = entry.format;
}
// status is intentionally NOT settable here — it's derived via deriveStatus()
@@ -93,8 +122,8 @@ async function applyAdvertisementFields(advertisement, body) {
if (body.is_active !== undefined) advertisement.is_active = body.is_active === true || body.is_active === "true";
if (body.size !== undefined) {
if (body.size !== null && !["sm", "md", "lg"].includes(body.size)) {
const err = new Error(`Invalid size. Must be one of: sm, md, lg`);
if (body.size !== null && !["sm", "md", "lg", "xl"].includes(body.size)) {
const err = new Error(`Invalid size. Must be one of: sm, md, lg, xl`);
err.status = 400;
throw err;
}
@@ -120,7 +149,7 @@ exports.getAdvertisements = async (req, res) => {
include: [{
model: mdl_Assets,
as: "image",
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"],
attributes: AD_IMAGE_ATTRIBUTES,
required: false,
}],
},
@@ -129,7 +158,10 @@ exports.getAdvertisements = async (req, res) => {
// Resync status on the way out — never trust what's stored, since
// start_date/end_date may have lapsed since the row was last saved.
if (Array.isArray(result?.data)) {
result.data = result.data.map((row) => ({ ...row, status: deriveStatus(row) }));
result.data = await Promise.all(result.data.map(async (row) => {
if (row.image) await attachImageStreamToken(row.image, req);
return { ...row, status: deriveStatus(row) };
}));
}
return R.success(res, "Advertisements retrieved.", result);
@@ -149,7 +181,7 @@ exports.getAdvertisement = async (req, res) => {
const advertisement = await Advertisement.findOne({
where: { advertisement_id: advertisementId, ...notDeleted },
include: [
{ model: mdl_Assets, as: "image", attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"], required: false },
{ model: mdl_Assets, as: "image", attributes: AD_IMAGE_ATTRIBUTES, required: false },
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
],
@@ -160,6 +192,8 @@ exports.getAdvertisement = async (req, res) => {
const json = advertisement.toJSON();
json.status = deriveStatus(json);
if (json.image) await attachImageStreamToken(json.image, req);
if (json.creator) {
json.creator = {
user_id: json.creator.user_id,
@@ -184,20 +218,21 @@ exports.getAdvertisement = async (req, res) => {
exports.createAdvertisement = async (req, res) => {
try {
const { type, createdBy } = req.body;
const { placement, createdBy } = req.body;
if (!type) return R.error(res, "type is required.", 400);
if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 400);
if (!placement) return R.error(res, "placement is required.", 400);
const entry = PLACEMENT_MAP[placement];
if (!entry) return R.error(res, `Invalid placement. Must be one of: ${PLACEMENT_KEYS.join(", ")}`, 400);
if (!createdBy) return R.error(res, "createdBy is required.", 400);
const t = await sequelize.transaction();
try {
const advertisement = await Advertisement.build({ type, createdBy });
const advertisement = await Advertisement.build({ placement, type: entry.format, createdBy });
await applyAdvertisementFields(advertisement, req.body);
await advertisement.save({ transaction: t });
await t.commit();
logActivity(req.user?.user_id, 'create_advertisement', { entityType: 'advertisement', entityId: advertisement.advertisement_id, details: { type: advertisement.type } });
logActivity(req.user?.user_id, 'create_advertisement', { entityType: 'advertisement', entityId: advertisement.advertisement_id, details: { placement: advertisement.placement, type: advertisement.type } });
return R.success(res, "Advertisement created.", { data: advertisement }, 201);
} catch (dbErr) {
try { await t.rollback(); } catch { /* connection gone */ }
+94 -11
View File
@@ -6,6 +6,7 @@ const sequelize = require("../../config/db.config");
const Asset = require("../../models/assets/assets.mdl");
const chibi = require("../../services/chibisafe.service");
const s3 = require("../../services/s3.service");
const mediaToken = require("../../services/mediaToken.service");
const { extractVideoMeta } = require("../../services/ffprobe.service");
const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
@@ -20,6 +21,29 @@ const { Op } = require('sequelize');
const notDeleted = { deletedAt: null };
// List queries keep storage_key selected (unlike adminExclude) so
// attachStreamTokens can sign a stream token server-side without a second
// query — it's deleted from every row before the response is sent.
const LIST_QUERY_EXCLUDE = adminExclude.filter((f) => f !== "storage_key");
// ─── In-memory list cache (no Redis yet) ───────────────────────────────────────
// Short TTL just to absorb bursts of identical GET /admin/assets calls — e.g.
// AssetPickerSheet being opened/closed repeatedly with the same filters — so
// Postgres isn't re-queried on every toggle. Cleared on any mutation below.
// Single-process only; fine for one instance, won't stay consistent across
// multiple app instances without a shared store like Redis.
const LIST_CACHE_TTL_MS = 20_000;
const listCache = new Map(); // queryKey -> { result, expiresAt }
function listCacheKey(req) {
return JSON.stringify({
page: req.query.page, limit: req.query.limit,
filters: req.query.filters, sort: req.query.sort,
});
}
function invalidateListCache() { listCache.clear(); }
function resolveFileType(mimeType = "") {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
@@ -166,20 +190,63 @@ function redactS3Url(asset) {
return asset;
}
// ─── attachStreamTokens ─────────────────────────────────────────────────────
//
// Embeds a stream_token (+ presigned thumbnail_url) directly into each S3 row
// so pickers/tables reading the list can render thumbnails immediately instead
// of firing a second POST /admin/media/tokens round-trip and waiting on it.
// storage_key is kept out of the DB attribute exclude list (unlike the rest of
// adminExclude) purely so it's available here to sign the token — it's still
// stripped from every row before the response goes out.
//
// Operates on shallow copies: `result.data` is shared with listCache, and
// mutating those rows in place would delete storage_key from the cached
// objects, breaking token issuance for the next request that hits the cache.
//
async function attachStreamTokens(rows, req) {
const ip = mediaToken.resolveIp(req);
const userId = req.user?.user_id;
return Promise.all(rows.map(async (original) => {
const row = { ...original };
const eligible = row.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(row.file_type);
if (eligible) {
const { token, thumbnail_url } = await mediaToken.issueForAsset(row, userId, ip);
row.stream_token = token;
if (thumbnail_url) row.thumbnail_url = thumbnail_url;
}
delete row.storage_key;
return row;
}));
}
// ─── GET ALL ──────────────────────────────────────────────────────────────────
exports.getAssets = async (req, res) => {
try {
const result = await paginate(Asset, req, {
excludeAttributes: adminExclude,
jsonbSchemas,
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'Asset' },
findOptions: { where: { deletedAt: null } },
});
result.data = result.data.map(redactS3Url);
return R.success(res, "Assets retrieved.", result);
const key = listCacheKey(req);
const cached = listCache.get(key);
let result;
if (cached && Date.now() < cached.expiresAt) {
result = cached.result;
} else {
result = await paginate(Asset, req, {
excludeAttributes: LIST_QUERY_EXCLUDE,
jsonbSchemas,
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'Asset' },
findOptions: { where: { deletedAt: null } },
});
result.data = result.data.map(redactS3Url);
listCache.set(key, { result, expiresAt: Date.now() + LIST_CACHE_TTL_MS });
}
const data = await attachStreamTokens(result.data, req);
return R.success(res, "Assets retrieved.", { ...result, data });
} catch (err) {
console.error("[ASSET][GET ALL]", err);
return R.error(res, "Could not retrieve assets.", 500);
@@ -195,7 +262,9 @@ exports.getAsset = async (req, res) => {
const asset = await Asset.findOne({
where: { asset_id: assetId, ...notDeleted },
attributes: { exclude: ["storage_key", "storage_bucket"] },
// storage_key stays selected here (unlike the list query) so it's
// available below to sign a stream token — stripped before the response.
attributes: { exclude: ["storage_bucket"] },
include: [
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
@@ -219,6 +288,14 @@ exports.getAsset = async (req, res) => {
};
}
if (json.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(json.file_type)) {
const ip = mediaToken.resolveIp(req);
const { token, thumbnail_url } = await mediaToken.issueForAsset(json, req.user?.user_id, ip);
json.stream_token = token;
if (thumbnail_url) json.thumbnail_url = thumbnail_url;
}
delete json.storage_key;
redactS3Url(json);
return R.success(res, "Asset retrieved.", { data: json });
} catch (err) {
@@ -404,6 +481,7 @@ exports.uploadAsset = async (req, res) => {
}, { transaction: t });
await t.commit();
invalidateListCache();
logActivity(req.user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
return R.success(res, "Asset uploaded.", { data: asset }, 201);
@@ -471,6 +549,7 @@ exports.updateAsset = async (req, res) => {
if (uploaded) await deleteOldFile(storageProvider, oldStorageKey, uploaded.storage_key);
invalidateListCache();
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "Asset updated.", { data: asset });
@@ -494,6 +573,7 @@ exports.archiveAsset = async (req, res) => {
await asset.update({ deletedBy: req.body.deletedBy ?? null });
await asset.destroy();
invalidateListCache();
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "Asset archived.");
} catch (err) {
@@ -517,6 +597,7 @@ exports.archiveAssets = async (req, res) => {
await Asset.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } });
await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
invalidateListCache();
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
return R.success(res, `${activeIds.length} asset(s) archived.`, {
archived_ids: activeIds,
@@ -540,6 +621,7 @@ exports.restoreAsset = async (req, res) => {
await asset.restore();
await asset.update({ deletedBy: null });
invalidateListCache();
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "Asset restored.", { data: asset });
} catch (err) {
@@ -566,6 +648,7 @@ exports.restoreAssets = async (req, res) => {
await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } });
await Asset.update({ deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false });
invalidateListCache();
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
return R.success(res, `${archivedIds.length} asset(s) restored.`, {
restored_ids: archivedIds,
+182 -21
View File
@@ -24,10 +24,13 @@ const {
UnitQuiz, QuizQuestion, QuizOption,
QuizAttempt, AssessmentSession,
CourseInstructor, CourseAchievement,
UnitReadingProgress, LessonReadingProgress,
} = require("../../models/courses/courses.associations");
const mdl_Users = require("../../models/users/users.mdl");
const { mdl_PlanCourses, mdl_TierPlans } = require("../../models/tiers/tier.associations");
const {
excludeAttributes: courseExclude,
computedAttributes: courseComputed,
@@ -46,7 +49,15 @@ exports.getCourses = async (req, res) => {
try {
const result = await paginate(Course, req, {
excludeAttributes: courseExclude,
computedAttributes: courseComputed,
computedAttributes: [
...courseComputed,
{
key: "assessment_id",
label: "Assessment ID",
type: "text",
literal: `(SELECT assessment_id FROM course_assessments WHERE course_id = "Course"."course_id" AND "deletedAt" IS NULL LIMIT 1)`,
},
],
auditOptions: { mdl_Users, parentAlias: "Course" },
context: "list",
findOptions: { where: { ...notDeleted } },
@@ -126,7 +137,7 @@ exports.createCourse = async (req, res) => {
if (achievement_keys.length) {
await CourseAchievement.bulkCreate(
achievement_keys.slice(0, 3).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
achievement_keys.slice(0, 1).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
{ transaction: t },
);
}
@@ -278,6 +289,30 @@ exports.bulkRestoreCourses = async (req, res) => {
}
};
exports.getCourseArchiveImpact = async (req, res) => {
try {
const { courseId } = req.params;
const [activeCount, totalCount] = await Promise.all([
UnitReadingProgress.count({
where: { course_id: courseId, status: "in_progress" },
distinct: true,
col: "user_id",
}),
UnitReadingProgress.count({
where: { course_id: courseId },
distinct: true,
col: "user_id",
}),
]);
return R.success(res, "Impact retrieved.", { activeCount, totalCount });
} catch (err) {
console.error("[COURSE][ARCHIVE IMPACT]", err);
return R.error(res, "Could not retrieve impact.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// COURSE PREREQUISITES
// ══════════════════════════════════════════════════════════════════════════════
@@ -364,6 +399,22 @@ exports.getUnits = async (req, res) => {
}
};
exports.getUnitArchiveImpact = async (req, res) => {
try {
const { unitId } = req.params;
const [completionCount, progressCount] = await Promise.all([
UnitReadingProgress.count({ where: { unit_id: unitId, status: "completed" } }),
LessonReadingProgress.count({ where: { unit_id: unitId }, distinct: true, col: "user_id" }),
]);
return R.success(res, "Impact retrieved.", { completionCount, progressCount });
} catch (err) {
console.error("[UNIT][ARCHIVE IMPACT]", err);
return R.error(res, "Could not retrieve impact.", 500);
}
};
exports.getUnit = async (req, res) => {
try {
const { courseId, unitId } = req.params;
@@ -1234,6 +1285,87 @@ exports.bulkRestoreQuestions = async (req, res) => {
}
};
exports.bulkSyncQuestions = async (req, res) => {
const t = await sequelize.transaction();
try {
const parent = await resolveQuestionParent(req.params);
if (!parent?.parentRecord) return R.error(res, "Parent not found.", 404);
const { questions = [], updatedBy } = req.body;
const existing = await QuizQuestion.findAll({
where: { [parent.parentField]: parent.parentId, ...notDeleted },
});
const existingIds = existing.map((q) => q.question_id);
const incomingIds = questions.filter((q) => q.question_id).map((q) => q.question_id);
const toArchive = existingIds.filter((id) => !incomingIds.includes(id));
if (toArchive.length) {
await QuizQuestion.update(
{ deletedAt: new Date(), deletedBy: updatedBy ?? null },
{ where: { question_id: toArchive }, transaction: t }
);
}
const result = [];
for (let i = 0; i < questions.length; i++) {
const { question_id, type, question, explanation, points, options = [] } = questions[i];
if (question_id && existingIds.includes(question_id)) {
const q = existing.find((e) => e.question_id === question_id);
q.type = type ?? q.type;
q.question = question ?? q.question;
q.explanation = explanation ?? null;
q.order_index = i;
q.points = points ?? q.points;
q.updatedBy = updatedBy ?? null;
await q.save({ transaction: t });
await QuizOption.destroy({ where: { question_id }, transaction: t });
if (options.length) {
await QuizOption.bulkCreate(
options.map((o, oi) => ({ question_id, text: o.text, is_correct: o.is_correct ?? false, order_index: oi })),
{ transaction: t }
);
}
result.push(question_id);
} else {
const q = await QuizQuestion.create({
[parent.parentField]: parent.parentId,
type, question,
explanation: explanation ?? null,
order_index: i,
points: points ?? 1,
createdBy: updatedBy ?? null,
}, { transaction: t });
if (options.length) {
await QuizOption.bulkCreate(
options.map((o, oi) => ({ question_id: q.question_id, text: o.text, is_correct: o.is_correct ?? false, order_index: oi })),
{ transaction: t }
);
}
result.push(q.question_id);
}
}
await t.commit();
const synced = await QuizQuestion.findAll({
where: { question_id: result },
order: [["order_index", "ASC"]],
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
logActivity(req.user?.user_id, 'bulk_sync_questions', { entityType: 'question', details: { parentField: parent.parentField, parentId: parent.parentId, count: synced.length } });
return R.success(res, "Questions synced.", { data: synced });
} catch (err) {
await t.rollback();
console.error("[QUESTION][BULK SYNC]", err);
return R.error(res, "Could not sync questions.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// COURSE ASSESSMENT
// ══════════════════════════════════════════════════════════════════════════════
@@ -1341,11 +1473,12 @@ exports.updateAssessment = async (req, res) => {
const course = await Course.findOne({
where: { course_id: courseId },
attributes: ['title'],
attributes: ['title', 'uuid'],
});
const notify = NOTIFICATION_REGISTRY.assessment_updated.build({
assessmentTitle: assessment.title,
courseTitle: course?.title ?? null,
courseUuid: course?.uuid ?? null,
});
const now = new Date();
await UserNotification.bulkCreate(
@@ -1433,7 +1566,7 @@ exports.getCoursesFlat = async (req, res) => {
try {
const data = await Course.findAll({
where: notDeleted,
attributes: ["uuid", "title"],
attributes: ["uuid", "title", "subscription", "duration_seconds"],
order: [["title", "ASC"]],
});
return R.success(res, "Courses retrieved.", data);
@@ -1448,11 +1581,32 @@ exports.getCoursesBySubscription = async (req, res) => {
const { slug } = req.query;
if (!slug) return R.error(res, 'slug query param is required.', 400);
const data = await Course.findAll({
const rows = await Course.findAll({
where: { ...notDeleted, subscription: slug },
attributes: ['course_id', 'title', 'description', 'subscription'],
include: [{
model: mdl_PlanCourses,
as: 'planCourse',
required: false,
attributes: ['plan_id'],
include: [{
model: mdl_TierPlans,
as: 'plan',
attributes: ['plan_id', 'label'],
}],
}],
order: [['title', 'ASC']],
});
// Flatten so the frontend can just check `assigned_plan` — a course belongs
// to at most one plan (UNIQUE constraint on plan_courses.course_id).
const data = rows.map((c) => {
const plain = c.toJSON();
const assigned_plan = plain.planCourse?.plan ?? null;
delete plain.planCourse;
return { ...plain, assigned_plan };
});
return R.success(res, 'Courses retrieved.', data);
} catch (err) {
console.error('[COURSE][BY SUBSCRIPTION]', err);
@@ -1464,11 +1618,12 @@ exports.getUnitsFlat = async (req, res) => {
try {
const rows = await Unit.findAll({
where: notDeleted,
attributes: ["uuid", "title", "order_index"],
attributes: ["uuid", "title", "order_index", "duration_seconds"],
include: [{
model: Course,
as: "course",
attributes: ["title"],
attributes: ["title", "subscription"],
paranoid: false,
}],
order: [
[{ model: Course, as: "course" }, "title", "ASC"],
@@ -1476,10 +1631,12 @@ exports.getUnitsFlat = async (req, res) => {
],
});
const data = rows.map((u) => ({
uuid: u.uuid,
title: u.title,
order_index: u.order_index ?? 0,
course_title: u.course?.title ?? "",
uuid: u.uuid,
title: u.title,
order_index: u.order_index ?? 0,
duration_seconds: u.duration_seconds ?? 0,
course_title: u.course?.title ?? "",
subscription: u.course?.subscription ?? "free",
}));
return R.success(res, "Units retrieved.", data);
} catch (err) {
@@ -1492,15 +1649,17 @@ exports.getLessonsFlat = async (req, res) => {
try {
const rows = await Lesson.findAll({
where: notDeleted,
attributes: ["uuid", "title", "order_index"],
attributes: ["uuid", "title", "order_index", "duration_seconds"],
include: [{
model: Unit,
as: "unit",
attributes: ["title", "order_index"],
paranoid: false,
include: [{
model: Course,
as: "course",
attributes: ["title"],
attributes: ["title", "subscription"],
paranoid: false,
}],
}],
order: [
@@ -1510,12 +1669,14 @@ exports.getLessonsFlat = async (req, res) => {
],
});
const data = rows.map((l) => ({
uuid: l.uuid,
title: l.title,
order_index: l.order_index ?? 0,
unit_title: l.unit?.title ?? "",
unit_order: l.unit?.order_index ?? 0,
course_title: l.unit?.course?.title ?? "",
uuid: l.uuid,
title: l.title,
order_index: l.order_index ?? 0,
duration_seconds: l.duration_seconds ?? 0,
unit_title: l.unit?.title ?? "",
unit_order: l.unit?.order_index ?? 0,
course_title: l.unit?.course?.title ?? "",
subscription: l.unit?.course?.subscription ?? "free",
}));
return R.success(res, "Lessons retrieved.", data);
} catch (err) {
@@ -1801,8 +1962,8 @@ exports.syncCourseAchievements = async (req, res) => {
const { courseId } = req.params;
const { achievement_keys = [] } = req.body;
if (achievement_keys.length > 3)
return R.error(res, "Maximum 3 achievements allowed per course.", 400);
if (achievement_keys.length > 1)
return R.error(res, "Maximum 1 achievement allowed per course.", 400);
await CourseAchievement.destroy({ where: { course_id: courseId }, transaction: t });
@@ -20,6 +20,27 @@
---
## Placement Registry
Every advertisement belongs to a `placement` — a page + position slug drawn from a fixed
registry (`models/advertisements/advertisements.placements.js`). The placement determines
the advertisement's `type` (visual format) automatically; `type` is **never** accepted from
the client and is denormalized from the placement on every write.
| Placement key | Page | Position | Format |
|---|---|---|---|
| `dashboard.hero` | Dashboard | Hero (top of page) | `hero` |
| `dashboard.popup` | Dashboard | Popup (on load) | `popup` |
| `course_list.banner` | Courses | Banner (above course grid) | `banner` |
| `course_details.banner` | Course Details | Banner (below hero) | `banner` |
| `course_details.sidebar` | Course Details | Sidebar (beside course content) | `sidebar` |
| `plans.banner` | Plans | Banner (above plan cards) | `banner` |
Adding a new placement is a one-line addition to that registry file plus wiring the
corresponding client page to fetch/render it — nothing else needs to change.
---
## Status Derivation
Status is **never** trusted as stored — it is recomputed on every read and write:
@@ -93,6 +114,7 @@ Returns one advertisement with its `image` asset and audit user info.
"data": {
"advertisement_id": 1,
"uuid": "...",
"placement": "dashboard.hero",
"type": "hero",
"status": "active",
"badge_label": "New",
@@ -131,7 +153,7 @@ Returns one advertisement with its `image` asset and audit user info.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | **Yes** | `hero`, `banner`, `popup`, `sidebar` |
| `placement` | string | **Yes** | A placement registry key, e.g. `dashboard.hero` — see [Placement Registry](#placement-registry). Determines `type` automatically. |
| `createdBy` | number | **Yes** | User ID of creator |
| `badge_label` | string | No | Small label shown on the ad |
| `headline` | string | No | Main heading |
@@ -157,8 +179,8 @@ Returns one advertisement with its `image` asset and audit user info.
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `type is required.` |
| `400` | `Invalid type. Must be one of: hero, banner, popup, sidebar` |
| `400` | `placement is required.` |
| `400` | `Invalid placement. Must be one of: dashboard.hero, dashboard.popup, ...` |
| `400` | `createdBy is required.` |
| `400` | `Invalid size. Must be one of: sm, md, lg` |
@@ -171,7 +193,7 @@ Returns one advertisement with its `image` asset and audit user info.
Partial update. Only fields present in the body are changed. Status is recomputed after all fields are applied.
### Request Body
Same optional fields as Create. Does not accept `type` once set. Accepts `updatedBy`.
Same optional fields as Create. `type` is never accepted — it's always derived from `placement`. Accepts `updatedBy`.
### Response `200`
```json
@@ -279,6 +301,10 @@ Returns distinct values for filterable advertisement fields. Used by DataTable f
{
"status": "success",
"message": "Field values retrieved.",
"data": { "type": ["hero", "banner"], "status": ["active", "draft"] }
"data": {
"type": ["hero", "banner", "popup", "sidebar"],
"placement": ["dashboard.hero", "dashboard.popup", "course_list.banner"],
"status": ["active", "draft"]
}
}
```
@@ -0,0 +1,173 @@
'use strict';
const { Op } = require('sequelize');
const mdl_EmailBroadcast = require('../../models/email_templates/email_broadcast.mdl');
const mdl_EmailBroadcastRecipient = require('../../models/email_templates/email_broadcast_recipient.mdl');
const mdl_EmailTemplate = require('../../models/email_templates/email_templates.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const {
ALLOWED_TARGET_TYPES,
SCOPED_TARGET_TYPES,
validateTargetId,
resolveTargetUserIds,
resolveAllUserIds,
} = require('../../utils/audienceResolver.util');
// Only these categories are ever appropriate to blast to real recipients —
// system/transactional templates (OTP, welcome, banned, etc.) are triggered
// per-user by app events and are deliberately NOT broadcastable here.
const BROADCASTABLE_CATEGORIES = ['announcement', 'advertisement'];
// 'admin' means "email every admin/staff user" for this feature — distinct
// from notification broadcasts' 'admin' (which posts to the shared bell feed
// instead of emailing anyone). Kept local to this controller for that reason.
async function resolveAdminStaffUserIds() {
const users = await mdl_Users.findAll({
attributes: ['user_id'],
where: { acc_type: { [Op.in]: ['admin', 'staff'] }, deletedAt: null },
raw: true,
});
return users.map((u) => String(u.user_id));
}
async function resolveAudienceUserIds(target_type, target_id) {
if (target_type === 'admin') return resolveAdminStaffUserIds();
if (target_type === 'user') return resolveAllUserIds();
if (target_type === 'both') {
const [admins, users] = await Promise.all([resolveAdminStaffUserIds(), resolveAllUserIds()]);
return [...new Set([...admins, ...users])];
}
return resolveTargetUserIds(target_type, target_id);
}
// ─── GET /admin/email-broadcasts ──────────────────────────────────────────────
exports.getEmailBroadcasts = async (req, res) => {
try {
const broadcasts = await mdl_EmailBroadcast.findAll({
order: [['createdAt', 'DESC']],
include: [{ model: mdl_EmailTemplate, as: 'template', attributes: ['email_template_id', 'type', 'label', 'category'] }],
});
return R.success(res, 'Email broadcasts retrieved.', broadcasts);
} catch (err) {
console.error('[ADMIN][GET EMAIL BROADCASTS]', err);
return R.error(res, 'Could not retrieve email broadcasts.', 500);
}
};
// ─── GET /admin/email-broadcasts/:id ──────────────────────────────────────────
exports.getEmailBroadcast = async (req, res) => {
try {
const broadcast = await mdl_EmailBroadcast.findByPk(req.params.id, {
include: [{ model: mdl_EmailTemplate, as: 'template', attributes: ['email_template_id', 'type', 'label', 'category'] }],
});
if (!broadcast) return R.error(res, 'Email broadcast not found.', 404);
const failedRecipients = await mdl_EmailBroadcastRecipient.findAll({
where: { email_broadcast_id: broadcast.email_broadcast_id, status: 'failed' },
attributes: ['email', 'error'],
limit: 50,
});
return R.success(res, 'Email broadcast retrieved.', { ...broadcast.toJSON(), failed_recipients: failedRecipients });
} catch (err) {
console.error('[ADMIN][GET EMAIL BROADCAST]', err);
return R.error(res, 'Could not retrieve email broadcast.', 500);
}
};
// ─── POST /admin/email-broadcasts ─────────────────────────────────────────────
// Enqueues recipients and returns immediately — actual sending happens later,
// paced, in cron/jobs/dispatch_email_broadcasts.cron.js. Never loops over
// recipients or calls sendEmail() here.
exports.createEmailBroadcast = async (req, res) => {
try {
const { email_template_id, target_type, target_id, createdBy } = req.body;
if (!email_template_id) return R.error(res, 'email_template_id is required.', 400);
if (!target_type) return R.error(res, 'target_type is required.', 400);
if (!ALLOWED_TARGET_TYPES.includes(target_type)) return R.error(res, `Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(', ')}`, 400);
if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, 'target_id is required for this target_type.', 400);
const template = await mdl_EmailTemplate.findByPk(email_template_id);
if (!template) return R.error(res, 'Email template not found.', 404);
if (!BROADCASTABLE_CATEGORIES.includes(template.category)) {
return R.error(res, `Only ${BROADCASTABLE_CATEGORIES.join('/')} templates can be sent to recipients.`, 400);
}
if (template.status !== 'sent' || !template.subject || !template.html_body) {
return R.error(res, 'This template has no published (sent) version yet — publish it before sending to recipients.', 400);
}
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
const userIds = await resolveAudienceUserIds(target_type, target_id);
if (!userIds.length) return R.error(res, 'No recipients match this target.', 400);
const users = await mdl_Users.findAll({
attributes: ['user_id', 'email', 'personal_info'],
where: { user_id: { [Op.in]: userIds }, email: { [Op.ne]: null }, deletedAt: null },
raw: true,
});
if (!users.length) return R.error(res, 'None of the matched recipients have a usable email address.', 400);
const broadcast = await mdl_EmailBroadcast.create({
email_template_id,
target_type,
target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null,
status: 'queued',
total_recipients: users.length,
createdBy: createdBy ?? req.user?.user_id ?? null,
});
const now = new Date();
await mdl_EmailBroadcastRecipient.bulkCreate(
users.map((u) => ({
email_broadcast_id: broadcast.email_broadcast_id,
user_id: u.user_id,
email: u.email,
name: u.personal_info?.name?.full_name ?? null,
status: 'pending',
createdAt: now,
updatedAt: now,
})),
{ validate: false }
);
logActivity(req.user?.user_id, 'create_email_broadcast', {
entityType: 'email_broadcast',
entityId: broadcast.email_broadcast_id,
details: { email_template_id, target_type, target_id, recipient_count: users.length },
});
return R.success(res, `Broadcast queued for ${users.length} recipient(s).`, broadcast, 201);
} catch (err) {
console.error('[ADMIN][CREATE EMAIL BROADCAST]', err);
if (err.status) return R.error(res, err.message, err.status);
return R.error(res, 'Internal server error.', 500);
}
};
// ─── PATCH /admin/email-broadcasts/:id/cancel ─────────────────────────────────
// Soft stop — the cron simply won't pick up any more pending recipients for a
// canceled broadcast. Whatever's already sent stays sent.
exports.cancelEmailBroadcast = async (req, res) => {
try {
const broadcast = await mdl_EmailBroadcast.findByPk(req.params.id);
if (!broadcast) return R.error(res, 'Email broadcast not found.', 404);
if (broadcast.status === 'completed' || broadcast.status === 'canceled') {
return R.error(res, `Broadcast is already ${broadcast.status}.`, 400);
}
await broadcast.update({ status: 'canceled' });
logActivity(req.user?.user_id, 'cancel_email_broadcast', { entityType: 'email_broadcast', entityId: broadcast.email_broadcast_id });
return R.success(res, 'Email broadcast canceled.', broadcast);
} catch (err) {
console.error('[ADMIN][CANCEL EMAIL BROADCAST]', err);
return R.error(res, 'Internal server error.', 500);
}
};
@@ -0,0 +1,173 @@
'use strict';
const mdl_EmailTemplate = require('../../models/email_templates/email_templates.mdl');
const mdl_EmailBroadcast = require('../../models/email_templates/email_broadcast.mdl');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const TYPE_PATTERN = /^[A-Z][A-Z0-9_]*$/;
const VALID_CATEGORIES = ['announcement', 'advertisement', 'system', 'other'];
// ─── GET /admin/email-templates ───────────────────────────────────────────────
exports.getEmailTemplates = async (req, res) => {
try {
const templates = await mdl_EmailTemplate.findAll({
order: [['category', 'ASC'], ['is_system', 'DESC'], ['type', 'ASC']],
});
return R.success(res, 'Email templates retrieved.', templates);
} catch (err) {
console.error('[ADMIN][GET EMAIL TEMPLATES]', err);
return R.error(res, 'Could not retrieve email templates.', 500);
}
};
// ─── GET /admin/email-templates/:id ───────────────────────────────────────────
exports.getEmailTemplate = async (req, res) => {
try {
const template = await mdl_EmailTemplate.findByPk(req.params.id);
if (!template) return R.error(res, 'Email template not found.', 404);
return R.success(res, 'Email template retrieved.', template);
} catch (err) {
console.error('[ADMIN][GET EMAIL TEMPLATE]', err);
return R.error(res, 'Could not retrieve email template.', 500);
}
};
// ─── POST /admin/email-templates ──────────────────────────────────────────────
exports.createEmailTemplate = async (req, res) => {
try {
const { type, label, category, subject, html_body, body_markdown, publish } = req.body;
if (!type || !label || !subject || !html_body) {
return R.error(res, 'type, label, subject and html_body are required.', 400);
}
if (!TYPE_PATTERN.test(type)) {
return R.error(res, 'type must be uppercase letters, numbers or underscores, starting with a letter (e.g. INVOICE_RECEIPT).', 400);
}
if (category !== undefined && !VALID_CATEGORIES.includes(category)) {
return R.error(res, `category must be one of: ${VALID_CATEGORIES.join(', ')}.`, 400);
}
const exists = await mdl_EmailTemplate.findOne({ where: { type } });
if (exists) return R.error(res, `An email template with type "${type}" already exists.`, 409);
// "Send Now" writes straight to the live columns sendEmail() reads.
// "Save as Draft" keeps the content out of the live columns entirely, so
// there's nothing for sendEmail() to pick up until it's published.
const isPublishing = publish === true || publish === 'true';
const template = await mdl_EmailTemplate.create({
type,
label,
category: category || 'other',
status: isPublishing ? 'sent' : 'draft',
subject: isPublishing ? subject : null,
html_body: isPublishing ? html_body : null,
body_markdown: isPublishing ? (body_markdown ?? null) : null,
draft_subject: isPublishing ? null : subject,
draft_html_body: isPublishing ? null : html_body,
draft_body_markdown: isPublishing ? null : (body_markdown ?? null),
last_sent_at: isPublishing ? new Date() : null,
is_system: false, // only seed data may be system-protected
});
logActivity(req.user?.user_id, 'create_email_template', { entityType: 'email_template', details: { type, label, category: template.category, status: template.status } });
return R.success(res, 'Email template created.', template, 201);
} catch (err) {
console.error('[ADMIN][CREATE EMAIL TEMPLATE]', err);
return R.error(res, 'Could not create email template.', 500);
}
};
// ─── PUT /admin/email-templates/:id ───────────────────────────────────────────
exports.updateEmailTemplate = async (req, res) => {
try {
const template = await mdl_EmailTemplate.findByPk(req.params.id);
if (!template) return R.error(res, 'Email template not found.', 404);
const { type, label, category, subject, html_body, body_markdown, publish } = req.body;
if (template.is_system && type !== undefined && type !== template.type) {
return R.error(res, 'The type of a system email template cannot be changed.', 400);
}
if (type !== undefined && !TYPE_PATTERN.test(type)) {
return R.error(res, 'type must be uppercase letters, numbers or underscores, starting with a letter (e.g. INVOICE_RECEIPT).', 400);
}
if (category !== undefined && !VALID_CATEGORIES.includes(category)) {
return R.error(res, `category must be one of: ${VALID_CATEGORIES.join(', ')}.`, 400);
}
if (!template.is_system && type !== undefined && type !== template.type) {
const exists = await mdl_EmailTemplate.findOne({ where: { type } });
if (exists) return R.error(res, `An email template with type "${type}" already exists.`, 409);
}
if (subject !== undefined && !subject.trim()) return R.error(res, 'subject cannot be empty.', 400);
if (html_body !== undefined && !html_body.trim()) return R.error(res, 'html_body cannot be empty.', 400);
// "Send" publishes subject/html_body straight to the live columns that
// sendEmail() reads and clears any pending draft. A plain save (no
// publish flag) writes into draft_subject/draft_html_body instead, so
// real outgoing mail keeps using the last-published content until an
// admin comes back and explicitly sends again.
const isPublishing = publish === true || publish === 'true';
const nextSubject = subject ?? template.draft_subject ?? template.subject;
const nextHtmlBody = html_body ?? template.draft_html_body ?? template.html_body;
const nextMarkdown = body_markdown ?? template.draft_body_markdown ?? template.body_markdown;
await template.update({
type: (!template.is_system && type !== undefined) ? type : template.type,
label: label ?? template.label,
category: category ?? template.category,
...(isPublishing
? {
status: 'sent',
subject: nextSubject,
html_body: nextHtmlBody,
body_markdown: nextMarkdown,
draft_subject: null,
draft_html_body: null,
draft_body_markdown: null,
last_sent_at: new Date(),
}
: {
draft_subject: nextSubject,
draft_html_body: nextHtmlBody,
draft_body_markdown: nextMarkdown,
}),
});
logActivity(req.user?.user_id, 'update_email_template', { entityType: 'email_template', entityId: template.email_template_id, details: { type: template.type, published: isPublishing } });
return R.success(res, 'Email template updated.', template);
} catch (err) {
console.error('[ADMIN][UPDATE EMAIL TEMPLATE]', err);
return R.error(res, 'Could not update email template.', 500);
}
};
// ─── DELETE /admin/email-templates/:id ────────────────────────────────────────
exports.deleteEmailTemplate = async (req, res) => {
try {
const template = await mdl_EmailTemplate.findByPk(req.params.id);
if (!template) return R.error(res, 'Email template not found.', 404);
if (template.is_system) return R.error(res, 'Built-in system email templates cannot be deleted.', 400);
const broadcastCount = await mdl_EmailBroadcast.count({ where: { email_template_id: template.email_template_id } });
if (broadcastCount > 0) {
return R.error(res, `Cannot delete — ${broadcastCount} broadcast(s) reference this template. Its send history would be lost.`, 409);
}
await template.destroy();
logActivity(req.user?.user_id, 'delete_email_template', { entityType: 'email_template', details: { type: template.type } });
return R.success(res, 'Email template deleted.');
} catch (err) {
console.error('[ADMIN][DELETE EMAIL TEMPLATE]', err);
return R.error(res, 'Could not delete email template.', 500);
}
};
+12 -60
View File
@@ -13,37 +13,10 @@
"use strict";
const { Op } = require("sequelize");
const jwt = require("jsonwebtoken");
const R = require("../../utils/response.util");
const mdl_Assets = require("../../models/assets/assets.mdl");
const s3 = require("../../services/s3.service");
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
const TOKEN_TTL_SEC = 30 * 60; // 30 min — admin preview session
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
function resolveIp(req) {
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) return forwarded.split(",")[0].trim();
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
}
function signToken(asset, userId, ip) {
return jwt.sign(
{
asset_id: asset.asset_id,
user_id: userId,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip,
},
MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC }
);
}
const R = require("../../utils/response.util");
const mdl_Assets = require("../../models/assets/assets.mdl");
const mediaToken = require("../../services/mediaToken.service");
// ─── POST /admin/media/token ──────────────────────────────────────────────────
@@ -59,7 +32,7 @@ exports.issueToken = async (req, res) => {
if (!asset) return R.error(res, "Asset not found.", 404);
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
if (!mediaToken.SUPPORTED_TYPES.includes(asset.file_type)) {
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
}
@@ -67,18 +40,8 @@ exports.issueToken = async (req, res) => {
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
}
const ip = resolveIp(req);
const token = signToken(asset, req.user.user_id, ip);
// ── Presign thumbnail URL so the browser can load it directly ─────────────
let thumbnail_url = null;
if (asset.thumbnail_storage_key) {
try {
thumbnail_url = await s3.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC);
} catch {
// Non-fatal — thumbnail is cosmetic
}
}
const ip = mediaToken.resolveIp(req);
const { token, thumbnail_url } = await mediaToken.issueForAsset(asset, req.user.user_id, ip);
return R.success(res, "Token issued.", {
token,
@@ -116,26 +79,15 @@ exports.issueTokensBatch = async (req, res) => {
attributes: ["asset_id", "file_type", "storage_key", "mime_type", "thumbnail_storage_key"],
});
const ip = resolveIp(req);
const ip = mediaToken.resolveIp(req);
const tokens = {};
const thumbnails = {};
for (const asset of assets) {
tokens[String(asset.asset_id)] = signToken(asset, req.user.user_id, ip);
// For image/video assets with a thumbnail — presign it so the browser can
// load it directly from Garage without going through the stream proxy.
if (asset.thumbnail_storage_key) {
try {
thumbnails[String(asset.asset_id)] = await s3.getSignedDownloadUrl(
asset.thumbnail_storage_key,
TOKEN_TTL_SEC,
);
} catch {
// Non-fatal — stream token is the fallback
}
}
}
await Promise.all(assets.map(async (asset) => {
const { token, thumbnail_url } = await mediaToken.issueForAsset(asset, req.user.user_id, ip);
tokens[String(asset.asset_id)] = token;
if (thumbnail_url) thumbnails[String(asset.asset_id)] = thumbnail_url;
}));
return R.success(res, "Tokens issued.", { tokens, thumbnails });
} catch (err) {
@@ -0,0 +1,421 @@
// controllers/admin/notificationBroadcasts.controller.js
const sequelize = require("../../config/db.config");
const NotificationBroadcast = require("../../models/notifications/notification_broadcast.mdl");
const AdminNotification = require("../../models/notifications/admin_notification.mdl");
const UserNotification = require("../../models/notifications/user_notification.mdl");
const mdl_Users = require('../../models/users/users.mdl');
const { TaskList } = require('../../models/task/task.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { Course } = require('../../models/courses/courses.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/notifications/notification_broadcast.attributes");
const logActivity = require('../../utils/logActivity.util');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const {
ALLOWED_TARGET_TYPES,
SCOPED_TARGET_TYPES,
validateTargetId,
resolveTaskListUserGroups,
resolveTargetUserIds,
} = require('../../utils/audienceResolver.util');
const { Op } = require('sequelize');
// ─── Helpers ──────────────────────────────────────────────────────────────────
const notDeleted = { deletedAt: null };
async function applyBroadcastFields(broadcast, body) {
if (body.title !== undefined) broadcast.title = body.title;
if (body.message !== undefined) broadcast.message = body.message;
if (body.target_type !== undefined) {
if (!ALLOWED_TARGET_TYPES.includes(body.target_type)) {
const err = new Error(`Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`);
err.status = 400;
throw err;
}
if (SCOPED_TARGET_TYPES.includes(body.target_type)) {
if (!body.target_id) {
const err = new Error("target_id is required for this target_type.");
err.status = 400;
throw err;
}
await validateTargetId(body.target_type, body.target_id);
broadcast.target_id = String(body.target_id);
} else {
broadcast.target_id = null;
}
broadcast.target_type = body.target_type;
}
}
// ─── Target resolution ────────────────────────────────────────────────────────
// task_list/course/tier_plan resolution now lives in utils/audienceResolver.util.js
// (resolveTargetUserIds, resolveTaskListUserGroups — imported above) so email
// broadcasts resolve the same targets identically.
// Enrich one or many broadcast rows with a human-readable target_label.
async function attachTargetLabels(rows) {
const list = Array.isArray(rows) ? rows : [rows];
const idsByType = { task_list: [], course: [], tier_plan: [] };
list.forEach((r) => { if (SCOPED_TARGET_TYPES.includes(r.target_type) && r.target_id) idsByType[r.target_type].push(r.target_id); });
const [taskLists, courses, plans] = await Promise.all([
idsByType.task_list.length ? TaskList.findAll({ where: { task_list_id: { [Op.in]: idsByType.task_list } }, attributes: ['task_list_id', 'name'], paranoid: false }) : [],
idsByType.course.length ? Course.findAll({ where: { uuid: { [Op.in]: idsByType.course } }, attributes: ['uuid', 'title'], paranoid: false }) : [],
idsByType.tier_plan.length ? mdl_TierPlans.findAll({ where: { plan_id: { [Op.in]: idsByType.tier_plan } }, attributes: ['plan_id', 'label'], paranoid: false }) : [],
]);
const taskListMap = Object.fromEntries(taskLists.map((t) => [t.task_list_id, t.name]));
const courseMap = Object.fromEntries(courses.map((c) => [c.uuid, c.title]));
const planMap = Object.fromEntries(plans.map((p) => [String(p.plan_id), p.label]));
list.forEach((r) => {
if (r.target_type === 'task_list') r.target_label = taskListMap[r.target_id] ?? null;
else if (r.target_type === 'course') r.target_label = courseMap[r.target_id] ?? null;
else if (r.target_type === 'tier_plan') r.target_label = planMap[r.target_id] ?? null;
else r.target_label = null;
});
return rows;
}
// ─── GET ALL ──────────────────────────────────────────────────────────────────
exports.getBroadcasts = async (req, res) => {
try {
const result = await paginate(NotificationBroadcast, req, {
excludeAttributes: adminExclude,
jsonbSchemas,
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
findOptions: { where: { ...notDeleted } },
});
if (Array.isArray(result?.data)) await attachTargetLabels(result.data);
return R.success(res, "Notification broadcasts retrieved.", result);
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ALL]", err);
return R.error(res, "Could not retrieve notification broadcasts.", 500);
}
};
// ─── GET ONE ──────────────────────────────────────────────────────────────────
exports.getBroadcast = async (req, res) => {
try {
const { broadcastId } = req.params;
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
const broadcast = await NotificationBroadcast.findOne({
where: { broadcast_id: broadcastId, ...notDeleted },
include: [
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
],
});
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
const json = broadcast.toJSON();
if (json.creator) {
json.creator = {
user_id: json.creator.user_id,
full_name: json.creator.personal_info?.name?.full_name ?? null,
};
}
if (json.updater) {
json.updater = {
user_id: json.updater.user_id,
full_name: json.updater.personal_info?.name?.full_name ?? null,
};
}
await attachTargetLabels(json);
return R.success(res, "Notification broadcast retrieved.", { data: json });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ONE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── CREATE ───────────────────────────────────────────────────────────────────
exports.createBroadcast = async (req, res) => {
try {
const { title, message, target_type, target_id, createdBy } = req.body;
if (!title) return R.error(res, "title is required.", 400);
if (!message) return R.error(res, "message is required.", 400);
if (!target_type) return R.error(res, "target_type is required.", 400);
if (!ALLOWED_TARGET_TYPES.includes(target_type)) return R.error(res, `Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`, 400);
if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, "target_id is required for this target_type.", 400);
if (!createdBy) return R.error(res, "createdBy is required.", 400);
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
const t = await sequelize.transaction();
try {
const broadcast = await NotificationBroadcast.build({
title, message, createdBy, status: 'draft',
target_type,
target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null,
});
await broadcast.save({ transaction: t });
await t.commit();
logActivity(req.user?.user_id, 'create_notification_broadcast', { entityType: 'notification_broadcast', entityId: broadcast.broadcast_id, details: { target_type } });
return R.success(res, "Notification broadcast created.", { data: broadcast }, 201);
} catch (dbErr) {
try { await t.rollback(); } catch { /* connection gone */ }
throw dbErr;
}
} catch (err) {
console.error("[NOTIFICATION BROADCAST][CREATE]", err);
if (err.status) return R.error(res, err.message, err.status);
return R.error(res, "Internal server error.", 500);
}
};
// ─── UPDATE ───────────────────────────────────────────────────────────────────
exports.updateBroadcast = async (req, res) => {
try {
const { broadcastId } = req.params;
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be edited.", 400);
const t = await sequelize.transaction();
try {
await applyBroadcastFields(broadcast, req.body);
broadcast.updatedBy = req.body.updatedBy ?? null;
await broadcast.save({ transaction: t });
await t.commit();
logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
return R.success(res, "Notification broadcast updated.", { data: broadcast });
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
throw dbErr;
}
} catch (err) {
console.error("[NOTIFICATION BROADCAST][UPDATE]", err);
if (err.status) return R.error(res, err.message, err.status);
return R.error(res, "Internal server error.", 500);
}
};
// ─── SEND ─────────────────────────────────────────────────────────────────────
exports.sendBroadcast = async (req, res) => {
try {
const { broadcastId } = req.params;
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be sent.", 400);
const t = await sequelize.transaction();
try {
const now = new Date();
let recipientCount = 0;
const targetType = broadcast.target_type;
const targetId = broadcast.target_id;
const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({
title: broadcast.title,
message: broadcast.message,
targetType,
targetId,
});
if (targetType === 'admin' || targetType === 'both') {
await AdminNotification.create({ ...baseNotify, seen: false }, { transaction: t });
recipientCount += 1;
}
let userIds = [];
let groupByUser = {}; // only populated for task_list — one group_id per user, for deep-linking
if (targetType === 'user' || targetType === 'both') {
const users = await mdl_Users.findAll({
attributes: ['user_id'],
where: { acc_type: 'user', deletedAt: null },
raw: true,
transaction: t,
});
userIds = users.map((u) => String(u.user_id));
} else if (targetType === 'task_list') {
groupByUser = await resolveTaskListUserGroups(targetId);
userIds = Object.keys(groupByUser);
} else if (SCOPED_TARGET_TYPES.includes(targetType)) {
userIds = await resolveTargetUserIds(targetType, targetId);
}
if (userIds.length) {
await UserNotification.bulkCreate(
userIds.map((user_id) => ({
user_id,
...(targetType === 'task_list'
? NOTIFICATION_REGISTRY.broadcast.build({
title: broadcast.title, message: broadcast.message, targetType, targetId,
groupId: groupByUser[user_id] ?? null,
})
: baseNotify),
seen: false,
createdAt: now,
updatedAt: now,
})),
{ validate: false, transaction: t }
);
}
recipientCount += userIds.length;
broadcast.status = 'sent';
broadcast.sent_at = now;
broadcast.recipient_count = recipientCount;
await broadcast.save({ transaction: t });
await t.commit();
logActivity(req.user?.user_id, 'send_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId), details: { target_type: targetType, target_id: broadcast.target_id, recipient_count: recipientCount } });
return R.success(res, "Notification broadcast sent.", { data: broadcast });
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
throw dbErr;
}
} catch (err) {
console.error("[NOTIFICATION BROADCAST][SEND]", err);
if (err.status) return R.error(res, err.message, err.status);
return R.error(res, "Internal server error.", 500);
}
};
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
exports.archiveBroadcast = async (req, res) => {
try {
const { broadcastId } = req.params;
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
await broadcast.update({ deletedBy: req.body.deletedBy ?? null });
await broadcast.destroy();
logActivity(req.user?.user_id, 'archive_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
return R.success(res, "Notification broadcast archived.");
} catch (err) {
console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
exports.archiveBroadcasts = async (req, res) => {
try {
const { ids, deletedBy } = req.body;
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids }, ...notDeleted } });
if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404);
const activeIds = broadcasts.map((b) => b.broadcast_id);
await NotificationBroadcast.update({ deletedBy: deletedBy ?? null }, { where: { broadcast_id: { [Op.in]: activeIds } } });
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: activeIds } } });
logActivity(req.user?.user_id, 'bulk_archive_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: activeIds, count: activeIds.length } });
return R.success(res, `${activeIds.length} notification broadcast(s) archived.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
});
} catch (err) {
console.error("[NOTIFICATION BROADCAST][BULK ARCHIVE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
exports.restoreBroadcast = async (req, res) => {
try {
const { broadcastId } = req.params;
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
if (!broadcast.deletedAt) return R.error(res, "Notification broadcast is not archived.", 400);
await broadcast.restore();
await broadcast.update({ deletedBy: null });
logActivity(req.user?.user_id, 'restore_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
return R.success(res, "Notification broadcast restored.", { data: broadcast });
} catch (err) {
console.error("[NOTIFICATION BROADCAST][RESTORE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
exports.restoreBroadcasts = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids } }, paranoid: false });
if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404);
const archived = broadcasts.filter((b) => b.deletedAt);
if (!archived.length) return R.error(res, "All selected notification broadcasts are already active.", 400);
const archivedIds = archived.map((b) => b.broadcast_id);
await NotificationBroadcast.restore({ where: { broadcast_id: { [Op.in]: archivedIds } } });
await NotificationBroadcast.update({ deletedBy: null }, { where: { broadcast_id: { [Op.in]: archivedIds } }, paranoid: false });
logActivity(req.user?.user_id, 'bulk_restore_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: archivedIds, count: archivedIds.length } });
return R.success(res, `${archivedIds.length} notification broadcast(s) restored.`, {
restored_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
});
} catch (err) {
console.error("[NOTIFICATION BROADCAST][BULK RESTORE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
exports.getArchivedBroadcasts = async (req, res) => {
try {
const result = await paginate(NotificationBroadcast, req, {
excludeAttributes: adminExclude,
jsonbSchemas,
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
});
return R.success(res, "Archived notification broadcasts retrieved.", result);
} catch (err) {
console.error("[NOTIFICATION BROADCAST][GET ARCHIVED]", err);
return R.error(res, "Could not retrieve archived notification broadcasts.", 500);
}
};
@@ -0,0 +1,78 @@
// controllers/admin/notificationSettings.controller.js
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const { CRON_PRESETS, CRON_PRESET_BY_EXPRESSION } = require('../../data/cronPresets.data');
const { rescheduleJob, getOrCreateSetting } = require('../../cron/cronRegistry.util');
// ─── Job registry — which cron scope owns each job (for defaults + labels) ────
const JOBS = {
taskOverdue: { schedule: '0 * * * *', label: 'Task Overdue Alerts (Admin)', description: 'Notifies admins when tasks flip to overdue.' },
userNotifications: { schedule: '5 * * * *', label: 'Task Overdue Alerts (Users)', description: 'Notifies affected users when their tasks are marked overdue.' },
issueCertificates: { schedule: '0 * * * *', label: 'Certificate Issued', description: 'Notifies users when a course certificate is ready.' },
expireUserTiers: { schedule: '* * * * *', label: 'Tier Expired', description: 'Notifies users when their subscription tier expires.' },
};
// ─── GET ──────────────────────────────────────────────────────────────────────
exports.getSettings = async (req, res) => {
try {
const rows = [];
for (const [job_name, meta] of Object.entries(JOBS)) {
const row = await getOrCreateSetting(job_name, meta.schedule);
rows.push({
job_name,
enabled: row.enabled,
schedule: row.schedule,
preset: CRON_PRESET_BY_EXPRESSION[row.schedule] ?? null,
label: meta.label,
description: meta.description,
updatedAt: row.updatedAt,
});
}
return R.success(res, 'Notification settings retrieved.', rows);
} catch (err) {
console.error('[NOTIFICATION SETTINGS][GET]', err);
return R.error(res, 'Could not retrieve notification settings.', 500);
}
};
// ─── UPDATE ───────────────────────────────────────────────────────────────────
exports.updateSetting = async (req, res) => {
try {
const { jobName } = req.params;
const { enabled, preset, updatedBy } = req.body;
if (!JOBS[jobName]) return R.error(res, `Unknown job "${jobName}".`, 404);
const row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
if (!row) return R.error(res, 'Setting not found.', 404);
if (enabled !== undefined) row.enabled = enabled === true || enabled === 'true';
if (preset !== undefined) {
const schedule = CRON_PRESETS[preset];
if (!schedule) return R.error(res, `Invalid preset. Must be one of: ${Object.keys(CRON_PRESETS).join(', ')}`, 400);
row.schedule = schedule;
try {
rescheduleJob(jobName, schedule);
} catch (err) {
console.error('[NOTIFICATION SETTINGS][RESCHEDULE]', err);
return R.error(res, `Saved, but failed to reschedule the live job: ${err.message}`, 500);
}
}
row.updatedBy = updatedBy ?? null;
await row.save();
logActivity(req.user?.user_id, 'update_notification_setting', { entityType: 'cron_notification_setting', entityId: jobName, details: { enabled: row.enabled, schedule: row.schedule } });
return R.success(res, 'Notification setting updated.', { data: row });
} catch (err) {
console.error('[NOTIFICATION SETTINGS][UPDATE]', err);
return R.error(res, 'Internal server error.', 500);
}
};
-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 { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups } = require('../../models/task/task.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { adminExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes');
const R = require('../../utils/response.util');
@@ -623,6 +626,53 @@ exports.updateTask = async (req, res) => {
});
logActivity(req.user.user_id, 'update_task', { entityType: 'task', entityId: Number(taskId) });
// ── Notify assigned users when requirements changed ────────────────────
if (Array.isArray(requirements)) {
try {
const groupRows = await TaskListGroup.findAll({
where: { task_list_id: task.task_list_id },
attributes: ['group_id'],
});
const groupIds = groupRows.map((r) => r.group_id);
if (groupIds.length) {
const memberRows = await mdl_UserGroupMembers.findAll({
where: { group_id: groupIds },
attributes: ['user_id', 'group_id'],
});
// One notification per user — first group membership wins if they're in more than one.
const seenUsers = new Set();
const members = memberRows.filter(({ user_id }) => {
if (seenUsers.has(user_id)) return false;
seenUsers.add(user_id);
return true;
});
if (members.length) {
const now = new Date();
await UserNotification.bulkCreate(
members.map(({ user_id, group_id }) => ({
user_id,
...NOTIFICATION_REGISTRY.task_requirements_updated.build({
taskName: full.name,
taskListId: task.task_list_id,
groupId: group_id,
}),
seen: false,
createdAt: now,
updatedAt: now,
})),
{ validate: false }
);
}
}
} catch (notifyErr) {
console.error('[ADMIN][UPDATE TASK][NOTIFY]', notifyErr);
}
}
return R.success(res, 'Task updated successfully.', full);
} catch (err) {
await t.rollback();
+12
View File
@@ -37,8 +37,20 @@ const {
computedAttributes: paymentsComputed,
} = require('../../models/tiers/payments.attributes');
const cc = require('currency-codes');
const PENDING_PAYMENT_EXPIRY_MINUTES = 60;
// ─── CURRENCIES ───────────────────────────────────────────────────────────────
exports.getCurrencies = (req, res) => {
const list = cc.codes().map((code) => {
const entry = cc.code(code);
return { code: entry.code, name: entry.currency };
}).sort((a, b) => a.code.localeCompare(b.code));
return R.success(res, 'OK', list);
};
const expireStalePendingPayments = async () => {
const expiresBefore = new Date(Date.now() - PENDING_PAYMENT_EXPIRY_MINUTES * 60 * 1000);
await mdl_Payments.update(
+8
View File
@@ -37,6 +37,14 @@ exports.getUnits = async (req, res) => {
where: { course_id: courseId, ...notDeleted },
order: [["order_index", "ASC"]],
},
computedAttributes: [
{
key: "quiz_id",
label: "Quiz ID",
type: "text",
literal: `(SELECT quiz_id FROM unit_quizzes WHERE unit_id = "Unit"."unit_id" AND "deletedAt" IS NULL LIMIT 1)`,
},
],
});
return R.success(res, "Units retrieved.", result);
+2 -1
View File
@@ -186,6 +186,7 @@ exports.verifyOTP = async (req, res) => {
groupName: grp?.name ?? null,
groupCode: grp?.group_code ?? null,
accType: user.acc_type,
groupId: membership?.group_id ?? null,
}),
createdAt: now,
updatedAt: now,
@@ -407,7 +408,7 @@ exports.googleCallback = async (req, res) => {
UserNotification.bulkCreate([
{
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,
updatedAt: _now,
},
+101 -27
View File
@@ -2,11 +2,39 @@
const Advertisement = require("../../models/advertisements/advertisements.mdl");
const mdl_Assets = require("../../models/assets/assets.mdl");
const mediaToken = require("../../services/mediaToken.service");
const R = require('../../utils/response.util');
const { PLACEMENT_MAP } = require("../../models/advertisements/advertisements.placements");
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 ─────────────────────────────────────────────────────
// Mirrors admin controller's deriveStatus — single source of truth for what
@@ -25,42 +53,41 @@ function deriveStatus(advertisement) {
return "active";
}
// ─── GET ACTIVE ───────────────────────────────────────────────────────────────
//
// Resolves the single highest-priority live advertisement for a given placement
// type. "Live" means is_active = true AND within start_date/end_date window —
// ─── Live window helper ─────────────────────────────────────────────────────
// "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
// here since we want the DB to do the filtering/ordering, not JS.
function liveWhere(extra) {
const now = new Date();
return {
...extra,
is_active: true,
deletedAt: null,
[Op.and]: [
{ [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] },
{ [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] },
],
};
}
// ─── GET ACTIVE ───────────────────────────────────────────────────────────────
//
// GET /api/client/advertisements/active?type=hero
// 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 { type } = req.query;
const { placement } = 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();
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: {
type,
is_active: true,
deletedAt: null,
[Op.and]: [
{ [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] },
{ [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] },
],
},
where: liveWhere({ placement }),
order: [["order", "ASC"], ["createdAt", "DESC"]],
include: [{
model: mdl_Assets,
as: "image",
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"],
required: false,
}],
attributes: { exclude: ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"] },
include: [AD_IMAGE_INCLUDE],
attributes: { exclude: AD_CLIENT_EXCLUDE },
});
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();
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 });
} catch (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 ──────────────────────────────────────────────────────────────
//
// POST /api/client/advertisements/:advertisementId/click
+12 -70
View File
@@ -14,49 +14,17 @@
const R = require('../../utils/response.util');
const mdl_Users = require('../../models/users/users.mdl');
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 {
Course,
CourseAssessment,
QuizAttempt,
Certificate,
CourseInstructor,
} = require('../../models/courses/courses.associations');
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 ──────────────────────────────────
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);
}
// ── 2. Verify the user passed ──────────────────────────────────────────────
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 ─────────────────────────────────────────────────────
// ── 2. Get user's name ──────────────────────────────────────────────────────
const user = await mdl_Users.findByPk(user_id, { attributes: ['personal_info'] });
const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant';
// ── 4. Resolve or create the certificate record ────────────────────────────
// CockroachDB does not support findOrCreate (uses temp PL/pgSQL functions).
let cert = await Certificate.findOne({ where: { user_id, course_id: course.course_id } });
// ── 3. Resolve or create the certificate record ─────────────────────────────
// Shared with the hourly issuance cron (cron/jobs/issue_certificates.cron.js)
// so both write through the same cert_no/ref_no sequence.
const cert = await ensureCertificateRecord({ userId: user_id, courseId: course.course_id });
if (!cert) {
cert = await Certificate.create({
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,
});
return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
}
// Always use live instructors from course_instructors table for the PDF.
// Keep the snapshot in sync so it reflects the current state.
// Always use live instructors from course_instructors table for the PDF,
// in case they changed since the certificate row was created.
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 dateStr = fmtDate(issuedDate);
// ── 6. Generate PDF ────────────────────────────────────────────────────────
// ── 5. Generate PDF ────────────────────────────────────────────────────────
const pdf = await generateCertificate({
name: fullName,
course: course.title,
@@ -148,7 +90,7 @@ exports.getCertificate = async (req, res) => {
length: cert.length_str ?? '',
});
// ── 7. Stream response ─────────────────────────────────────────────────────
// ── 6. Stream response ─────────────────────────────────────────────────────
const nameParts = fullName.trim().split(/\s+/);
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0];
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
UserNotification.create({
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));
}
+14 -2
View File
@@ -63,11 +63,23 @@ function trackToken(token, 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) {
// x-forwarded-for is set by reverse proxies (nginx, Caddy, Cloudflare)
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) return forwarded.split(",")[0].trim();
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown");
return normalizeIp(raw);
}
// ─── Helper: pipe S3 pre-signed URL to response (Range-aware) ────────────────
+15 -1
View File
@@ -6,6 +6,7 @@
* GET /client/notifications/unseen — unseen count
* PATCH /client/notifications/:id/seen — mark one as seen
* PATCH /client/notifications/seen-all — mark all as seen
* DELETE /client/notifications/clear-all — delete all notifications
*
* Author: Kenneth Obsequio (@lash0000)
* 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 ──────────────────────────────────────────────────────────
exports.getSessions = async (req, res) => {
+42 -3
View File
@@ -22,6 +22,7 @@ const { userExclude } = require('../../models/task/task.attributes');
const { clientExclude } = require('../../models/task/task_completion.attributes');
const logActivity = require('../../utils/logActivity.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 isUUID = (v) => UUID_RE.test(v);
@@ -45,13 +46,32 @@ exports.getMyGroups = async (req, res) => {
attributes: [],
through: {
model: mdl_UserGroupMembers,
attributes: ['joined_at'],
attributes: [],
where: { deletedAt: null },
},
},
{
model: TaskList,
as: 'taskLists',
attributes: [],
through: { attributes: [] },
required: false,
},
],
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']],
});
@@ -175,6 +195,13 @@ exports.getGroupTaskList = async (req, res) => {
const json = taskList.toJSON();
const tasks = json.tasks ?? [];
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 ────────────────────
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 ──────────────────
const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []);
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 ────────────────────
const [completions, linkVisits, progressRows] = await Promise.all([
@@ -684,4 +723,4 @@ exports.getLatestCompletion = async (req, res) => {
console.error('[CLIENT][GET LATEST COMPLETION]', err);
return R.error(res, 'Could not retrieve latest completion.', 500);
}
};
};
+13 -1
View File
@@ -21,6 +21,7 @@
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const { Op } = require('sequelize');
const sequelize = require('../../config/db.config');
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 logActivity = require('../../utils/logActivity.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 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);
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([
TaskLinkVisit.findAll({
where: { task_id: taskId, user_id: req.user.user_id },
@@ -410,4 +422,4 @@ exports.updateProgress = async (req, res) => {
// console.error('[CLIENT][GET LATEST COMPLETION]', err);
// return R.error(res, 'Could not retrieve latest completion.', 500);
// }
// };
// };
+8 -28
View File
@@ -13,7 +13,6 @@
***********************************************************************************************************************************************************************/
const mdl_TierCategories = require('../../models/tiers/tier_categories.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_Payments = require('../../models/tiers/payments.mdl');
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
@@ -53,8 +52,9 @@ exports.getMyTier = async (req, res) => {
UserNotification.create({
user_id: req.user.user_id,
...NOTIFICATION_REGISTRY.tier_expired.build({
tier: tier.tier,
label: tier.plan?.label ?? null,
tier: tier.tier,
label: tier.plan?.label ?? null,
planId: tier.plan?.plan_id ?? null,
}),
}).catch(() => {});
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'],
through: { attributes: [] },
},
{
model: mdl_PlanPrices,
as: 'prices',
attributes: ['currency', 'price'],
},
],
});
@@ -139,21 +134,14 @@ exports.getPlans = async (req, res) => {
exports.validatePromo = async (req, res) => {
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);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
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 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);
} catch (err) {
@@ -166,22 +154,14 @@ exports.validatePromo = async (req, res) => {
exports.createOrder = async (req, res) => {
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);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
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
let effectivePrice = Number(plan.price);
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 effectivePrice = Number(plan.price);
const effectiveCurrency = plan.currency;
const policy = await paymentSvc.getPolicyForPlan(plan_id);
+29 -12
View File
@@ -14,8 +14,16 @@
* That's the only wiring required — server.js never needs to
* 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:
* - 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)
* Date Created: Jun. 17, 2026
@@ -23,25 +31,34 @@
const cron = require('node-cron');
const taskOverdue = require('./jobs/task_overdue.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 ─────────────────────────
const jobs = [
const settingsBackedJobs = [
taskOverdue,
];
// Plain hardcoded-schedule jobs (not tied to any notification setting).
const plainJobs = [
liftExpiredBans,
dispatchEmailBroadcasts,
];
// ─── Boot all registered admin-side jobs ──────────────────────────────────────
function startAdminCronJobs() {
const registered = [];
jobs.forEach(({ name, schedule, run }) => {
if (!cron.validate(schedule)) {
console.error(`[CRON][ADMIN] Invalid schedule for "${name}": "${schedule}" — skipped.`);
return;
async function startAdminCronJobs() {
const registered = await startSettingsBackedJobs(settingsBackedJobs, 'ADMIN');
for (const job of plainJobs) {
if (!cron.validate(job.schedule)) {
console.error(`[CRON][ADMIN] Invalid schedule for "${job.name}": "${job.schedule}" — skipped.`);
continue;
}
cron.schedule(schedule, run);
registered.push({ name, scope: 'ADMIN', schedule });
});
cron.schedule(job.schedule, job.run);
registered.push({ name: job.name, scope: 'ADMIN', schedule: job.schedule });
}
return registered;
}
module.exports = { startAdminCronJobs };
module.exports = { startAdminCronJobs };
+9 -13
View File
@@ -5,6 +5,11 @@
* Same shape as admin.cron.js — each job module exports
* { 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:
* - userNotifications (cron/jobs/user_notifications.cron.js)
* - issueCertificates (cron/jobs/issue_certificates.cron.js)
@@ -13,10 +18,10 @@
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 17, 2026
***********************************************************************************************************************************************************************/
const cron = require('node-cron');
const userNotifications = require('./jobs/user_notifications.cron');
const issueCertificates = require('./jobs/issue_certificates.cron');
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
const { startSettingsBackedJobs } = require('./cronRegistry.util');
// ─── Registry — add future client-side cron jobs here ────────────────────────
const jobs = [
@@ -26,17 +31,8 @@ const jobs = [
];
// ─── Boot all registered client-side jobs ─────────────────────────────────────
function startClientCronJobs() {
const registered = [];
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;
async function startClientCronJobs() {
return startSettingsBackedJobs(jobs, 'CLIENT');
}
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,
};
+20 -14
View File
@@ -27,6 +27,7 @@ const { Op } = require('sequelize');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.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');
require('../../models/tiers/tier.associations');
@@ -43,7 +44,7 @@ async function run() {
include: [{
model: mdl_TierPlans,
as: 'plan',
attributes: ['label', 'tier'],
attributes: ['plan_id', 'label', 'tier'],
required: false,
}],
attributes: ['tier_id', 'user_id', 'tier'],
@@ -69,20 +70,25 @@ async function run() {
}
// ── 3. Send in-app notifications (one per affected user) ──────────────────
const notifications = expired.map((t) =>
NOTIFICATION_REGISTRY.tier_expired.build({
tier: t.tier,
label: t.plan?.label ?? null,
})
).map((payload, i) => ({
user_id: expired[i].user_id,
...payload,
}));
// 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) =>
NOTIFICATION_REGISTRY.tier_expired.build({
tier: t.tier,
label: t.plan?.label ?? null,
planId: t.plan?.plan_id ?? null,
})
).map((payload, i) => ({
user_id: expired[i].user_id,
...payload,
}));
try {
await UserNotification.bulkCreate(notifications, { ignoreDuplicates: true });
} catch (err) {
console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err);
try {
await UserNotification.bulkCreate(notifications, { ignoreDuplicates: true });
} catch (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).`);
+27 -11
View File
@@ -9,8 +9,11 @@
* For each ready row it:
* 1. Grants the course_completed_<uuid> achievement (the key
* MyCertificates / Profile use to display certificate cards).
* 2. Sends a 'certificate_issued' UserNotification.
* 3. Marks the row processed_at = NOW() so it never fires again.
* 2. Persists the certificate record (cert_no/ref_no) via
* 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
* 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 mdl_Achievements = require('../../models/users/achievements.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 { ensureCertificateRecord } = require('../../services/certificate-record.service');
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 ────────────────────────────────────
let rows;
try {
@@ -69,16 +78,23 @@ async function run() {
});
}
// ── 3. Send certificate_issued notification ────────────────────────
await UserNotification.create({
user_id,
...NOTIFICATION_REGISTRY.certificate_issued.build({
courseTitle: course_title ?? '',
courseUuid: course_uuid,
}),
});
// ── 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. Mark row processed ─────────────────────────────────────────
// ── 4. Send certificate_issued notification ─────────────────────────
if (notificationsEnabled) {
await UserNotification.create({
user_id,
...NOTIFICATION_REGISTRY.certificate_issued.build({
courseTitle: course_title ?? '',
courseUuid: course_uuid,
}),
});
}
// ── 5. Mark row processed ─────────────────────────────────────────
await PendingCertificate.update(
{ processed_at: new Date() },
{ where: { pending_id } }
+5
View File
@@ -25,6 +25,7 @@
const { Op } = require('sequelize');
const { Task } = require('../../models/task/task.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');
// ─── The actual sweep ────────────────────────────────────────────────────────
@@ -52,7 +53,11 @@ async function run() {
console.log(`[CRON][TASK OVERDUE] Marked ${affectedCount} task(s) as overdue.`);
// ── 2. Secondary: admin notification — isolated, never blocks step 1 ─────
// Skippable via /admin/notifications/settings — the status flip above always happens either way.
try {
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskOverdue' } });
if (settings && !settings.enabled) return;
await AdminNotification.create(
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 { Task } = require('../../models/task/task.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 WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback
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 ──────────
let recentlyOverdue;
try {
+5 -105
View File
@@ -1,119 +1,19 @@
/***********************************************************************************************************************************************************************
* File Name: achievements.data.js
* Type of Program: Data
* Description: Static registry of all available achievements (badges and milestones).
* Add a new achievement here — no other code changes needed.
*
* Available keys:
* Badge:
* 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
* Description: Static config for the achievement system that isn't part of the
* admin-managed catalog. The achievement catalog itself (keys, type,
* label, description, icon) now lives in the achievement_definitions
* table — see models/users/achievement_definitions.mdl.js and
* controllers/admin/achievements.controller.js for CRUD.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
'use strict';
// ─── Early Access cutoff ──────────────────────────────────────────────────────
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 = {
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:
* 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,
* nogrp_welcome, tier_expired
* Both : broadcast (admin-composed, sent via notification_broadcasts CRUD)
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
@@ -87,6 +88,20 @@ const NOTIFICATION_REGISTRY = {
// ─────────────────────────────────────────────────────────────────────────
// ── 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: {
type: 'task',
scope: 'user',
@@ -106,12 +121,12 @@ const NOTIFICATION_REGISTRY = {
type: 'task',
scope: 'user',
trigger: 'cron',
build({ taskName, deadline }) {
build({ taskName, deadline, taskListId = null, groupId = null }) {
return {
type: 'task',
title: 'Task Deadline Approaching',
message: `"${taskName}" is due on ${fmtDate(deadline)}.`,
data: { taskName, deadline },
data: { taskName, deadline, taskListId, groupId },
};
},
},
@@ -136,12 +151,12 @@ const NOTIFICATION_REGISTRY = {
type: 'course',
scope: 'user',
trigger: 'event',
build({ courseTitle }) {
build({ courseTitle, courseUuid = null }) {
return {
type: 'course',
title: 'New Course Available',
message: `"${courseTitle}" has been added to your learning library.`,
data: { courseTitle },
data: { courseTitle, courseUuid },
};
},
},
@@ -150,12 +165,12 @@ const NOTIFICATION_REGISTRY = {
type: 'course',
scope: 'user',
trigger: 'event',
build({ courseTitle }) {
build({ courseTitle, courseUuid = null }) {
return {
type: 'course',
title: 'Course Completed',
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',
scope: 'user',
trigger: 'event',
build({ groupName, groupCode, accType }) {
build({ groupName, groupCode, accType, groupId = null }) {
const greeting = accType === 'admin'
? 'Welcome, Administrator!'
: accType === 'staff'
@@ -189,7 +204,7 @@ const NOTIFICATION_REGISTRY = {
type: 'announcement',
title: 'Welcome to Philproperties',
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',
scope: 'user',
trigger: 'event',
build({ assessmentTitle, courseTitle }) {
build({ assessmentTitle, courseTitle, courseUuid = null }) {
return {
type: 'assessment',
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.`,
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_expired: {
type: 'tier_expired',
scope: 'user',
trigger: 'cron',
build({ tier, label }) {
build({ tier, label, planId = null }) {
return {
type: 'tier_expired',
title: 'Subscription Expired',
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 },
// ─── 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: DataTypes.ENUM("hero", "banner", "popup", "sidebar"),
allowNull: false,
label: "Type", order: 1
filterable: true,
label: "Format", order: 2
},
status: {
type: DataTypes.ENUM("draft", "active", "scheduled", "expired", "archived"),
allowNull: false,
defaultValue: "draft", label: "Status", order: 2
defaultValue: "draft", label: "Status", order: 3
},
// ─── Content ──────────────────────────────────────────────────────────────
badge_label: { type: DataTypes.STRING(100), label: "Badge Label", order: 3 },
headline: { type: DataTypes.STRING(255), label: "Headline", order: 4 },
description: { type: DataTypes.TEXT, label: "Description", order: 5 },
badge_label: { type: DataTypes.STRING(100), label: "Badge Label", order: 4 },
headline: { type: DataTypes.STRING(255), label: "Headline", order: 5 },
description: { type: DataTypes.TEXT, label: "Description", order: 6 },
// ─── Media ────────────────────────────────────────────────────────────────
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 ──────────────────────────────────────────────────────
// [{ 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 ───────────────────────────────────────────────────────────
start_date: { type: DataTypes.DATE, label: "Start Date", order: 7 },
end_date: { type: DataTypes.DATE, label: "End Date", order: 8 },
start_date: { type: DataTypes.DATE, label: "Start Date", order: 8 },
end_date: { type: DataTypes.DATE, label: "End Date", order: 9 },
// ─── Display behavior ─────────────────────────────────────────────────────
order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: "order", label: "Order", order: 9 },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Active", order: 10 },
size: { type: DataTypes.ENUM("sm", "md", "lg"), allowNull: true, label: "Size", order: 11 }, // banner-only, ignored by other types
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: 11 },
size: { type: DataTypes.ENUM("sm", "md", "lg"), allowNull: true, label: "Size", order: 12 }, // banner-only, ignored by other types
// ─── 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 ─────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
@@ -58,6 +68,7 @@ const Advertisement = sequelize.define("Advertisement", {
indexes: [
{ fields: ["uuid"] },
{ fields: ["type"] },
{ fields: ["placement"] },
{ fields: ["status"] },
{ fields: ["is_active"] },
{ 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
* 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
* (cron/jobs/issue_certificates.cron.js) polls this table every
* 5 minutes and processes rows where issue_at <= NOW().
* (cron/jobs/issue_certificates.cron.js) runs hourly on the hour
* and processes rows where issue_at <= NOW().
*
* Author: Kenneth Obsequio (@lash0000)
* 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 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 };
+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 },
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 },
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 },
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 },
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 },
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' },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Updated 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_PlanCourses = require('./plan_courses.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 Asset = require('../assets/assets.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_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 ─────────────────────────────────────────────────────
mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' });
@@ -68,6 +63,5 @@ module.exports = {
mdl_Payments,
mdl_PlanCourses,
mdl_PlanPolicies,
mdl_PlanPrices,
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 },
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 },
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 },
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 },
// ── Currency preference ──────────────────────────────────────────────────────
preferred_currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Preferred Currency' },
// ── Ban state ────────────────────────────────────────────────────────────────
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" },
+26
View File
@@ -16,6 +16,7 @@
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"csurf": "^1.11.0",
"currency-codes": "^2.2.0",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-rate-limit": "^6.10.0",
@@ -3358,6 +3359,16 @@
"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": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -3966,6 +3977,12 @@
"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": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
@@ -6241,6 +6258,15 @@
"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": {
"version": "4.1.1",
"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",
"cors": "^2.8.5",
"csurf": "^1.11.0",
"currency-codes": "^2.2.0",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-rate-limit": "^6.10.0",
@@ -52,7 +53,9 @@
"sequelize-cli": "^6.6.5"
},
"jest": {
"testMatch": ["**/tests/**/*.test.js"],
"testMatch": [
"**/tests/**/*.test.js"
],
"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 advertisementRoutes = require('./advertisements.routes');
const notificationRoutes = require('./notifications.routes');
const notificationBroadcastRoutes = require('./notificationBroadcasts.routes');
const notificationSettingsRoutes = require('./notificationSettings.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');
// ── Guards — applied to ALL admin routes ──────────────────────────────────────
@@ -58,7 +63,12 @@ router.use('/categories', categoriesRoutes);
router.use('/products', productsRoutes);
router.use('/advertisements', advertisementRoutes);
router.use('/notifications', notificationRoutes);
router.use('/notification-broadcasts', notificationBroadcastRoutes);
router.use('/notification-settings', notificationSettingsRoutes);
router.use('/media', mediaRoutes);
router.use('/achievements', achievementsRoutes);
router.use('/email-templates', emailTemplatesRoutes);
router.use('/email-broadcasts', emailBroadcastsRoutes);
// ── Activity feed — global ────────────────────────────────────────────────────
router.get('/activity', activityCtrl.getActivity);
+4
View File
@@ -28,6 +28,7 @@ router.get("/lessons-flat", ctrl.getLessonsFlat);
// ── then :courseId ────────────────────────────────────────────────────────────
router.get("/archives/:courseId", ctrl.getArchivedCourse);
router.patch("/:courseId/restore", ctrl.restoreCourse);
router.get("/:courseId/archive-impact", ctrl.getCourseArchiveImpact);
router.get("/:courseId", ctrl.getCourse);
router.put("/:courseId", ctrl.updateCourse);
router.delete("/:courseId", ctrl.archiveCourse);
@@ -75,6 +76,7 @@ router.post("/:courseId/assessment/:assessmentId/questions", ctrl.createQuestion
// static before :questionId
router.delete("/:courseId/assessment/:assessmentId/questions/bulk", ctrl.bulkArchiveQuestions);
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.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.patch("/:courseId/units/:unitId/restore", ctrl.restoreUnit);
router.get("/:courseId/units/:unitId/archive-impact", ctrl.getUnitArchiveImpact);
router.get("/:courseId/units/:unitId", ctrl.getUnit);
router.put("/:courseId/units/:unitId", ctrl.updateUnit);
router.delete("/:courseId/units/:unitId", ctrl.archiveUnit);
@@ -125,6 +128,7 @@ router.post("/:courseId/units/:unitId/quiz/:quizId/questions", ctrl.createQuesti
// static before :questionId
router.delete("/:courseId/units/:unitId/quiz/:quizId/questions/bulk", ctrl.bulkArchiveQuestions);
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.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 router = express.Router();
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.post ('/', ctrl.createPlan);
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.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/courses', ctrl.getPlanCourses);
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.put ('/:id', ctrl.updatePlan);
router.delete('/:id', ctrl.archivePlan);
+4 -1
View File
@@ -2,9 +2,12 @@
const router = require('express').Router();
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);
// ─── GET /api/client/advertisements/active-batch?placements=a,b,c ─────────────
router.get('/active-batch', controller.getActiveAdvertisements);
// ─── POST /api/client/advertisements/:advertisementId/click ───────────────────
router.post('/:advertisementId/click', controller.trackClick);
-1
View File
@@ -50,7 +50,6 @@ router.use(authenticate, requireClient());
router.get('/profile', profileCtrl.getProfile);
router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile);
router.delete('/profile', profileCtrl.deleteAccount);
router.patch('/profile/currency', profileCtrl.updateCurrency);
router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar);
router.delete('/profile/avatar', profileCtrl.deleteAvatar);
router.get('/sessions', profileCtrl.getSessions);
+7 -5
View File
@@ -3,10 +3,11 @@
* Type : Router (Client)
* Description : Per-user notification endpoints.
*
* GET /api/client/notifications — paginated list
* GET /api/client/notifications/unseen — unseen count
* PATCH /api/client/notifications/seen-all — mark all seen
* PATCH /api/client/notifications/:id/seen — mark one seen
* GET /api/client/notifications — paginated list
* GET /api/client/notifications/unseen — unseen count
* PATCH /api/client/notifications/seen-all — mark all 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)
*
@@ -15,11 +16,12 @@
***********************************************************************************************************************************************************************/
const express = require('express');
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('/unseen', unseenCount);
router.patch('/seen-all', markAllSeen);
router.patch('/:id/seen', markSeen);
router.delete('/clear-all', clearAll);
module.exports = router;
-4
View File
@@ -1,7 +1,6 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../../controllers/client/tiers.controller');
const priceCtrl = require('../../controllers/admin/plan_prices.controller');
// My tier
router.get ('/me', ctrl.getMyTier);
@@ -28,7 +27,4 @@ router.get ('/categories', ctrl.getCategories);
// System badges (public read for profile display)
router.get ('/system-badges', ctrl.getSystemBadges);
// Supported currencies (public — used by currency picker in settings + checkout)
router.get ('/currencies', priceCtrl.getCurrencies);
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/notifications/admin_notification.mdl');
require('./models/notifications/user_notification.mdl');
require('./models/notifications/notification_broadcast.mdl');
// ── Cron jobs ──────────────────────────────────────────────────────────────────
const { startAdminCronJobs } = require('./cron/admin.cron');
@@ -169,8 +170,8 @@ function printCronTable(jobs) {
console.log('✅ Database connected.');
const cronJobs = [
...startAdminCronJobs(),
...startClientCronJobs(),
...(await startAdminCronJobs()),
...(await startClientCronJobs()),
];
app.listen(PORT, () => {
+9 -8
View File
@@ -10,10 +10,11 @@
***********************************************************************************************************************************************************************/
'use strict';
const { Op } = require('sequelize');
const mdl_Achievements = require('../models/users/achievements.mdl');
const mdl_Users = require('../models/users/users.mdl');
const { EARLY_ACCESS_CUTOFF, ACHIEVEMENT_REGISTRY } = require('../data/achievements.data');
const { Op } = require('sequelize');
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 { EARLY_ACCESS_CUTOFF } = require('../data/achievements.data');
const UserNotification = require('../models/notifications/user_notification.mdl');
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.
*
* @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 {string|number} granted_by — null = system, user_id = admin manual grant
* @returns {{ achievement, created }} or null on error
*/
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) {
console.warn(`[ACHIEVEMENTS] Unknown achievement key: "${key}"`);
console.warn(`[ACHIEVEMENTS] Unknown or inactive achievement key: "${key}"`);
return null;
}
@@ -47,6 +48,7 @@ async function grantAchievement(user_id, key, metadata = {}, granted_by = null)
key: def.key,
label: def.label,
description: def.description,
icon: def.icon,
granted_by: granted_by ?? null,
granted_at: new Date(),
metadata,
@@ -140,7 +142,6 @@ async function backfillEarlyAccess() {
// ─── Exports ──────────────────────────────────────────────────────────────────
module.exports = {
ACHIEVEMENT_REGISTRY,
grantAchievement,
// 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 };
+34 -11
View File
@@ -2,18 +2,22 @@
* File Name: email.service.js
* Type of Program: Service
* Description: Nodemailer-based email service.
* Provides:
* - sendOTPEmail() → sends a 6-digit OTP verification email
* - sendWelcomeEmail() → sent after successful email verification
* Author: rgrgogu
* Subject + body per type are loaded from the email_templates
* table (admin-editable, see controllers/admin/email_templates.controller.js).
* The outer layout (header/footer/signature) below is fixed in
* code and is NOT admin-editable — only the body content is.
* Author: rgrgogu, Kenneth Obsequio (@lash0000)
* Date Created: Oct. 6, 2025
* Date Modified: Jul. 3, 2026 — templates moved from data/email_body.data.js into the DB
***********************************************************************************************************************************************************************
* HOW TO USE:
* 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 { 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);
@@ -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 = {} }) => {
try {
const templateFn = emailTemplates[type];
if (!templateFn) {
const template = await mdl_EmailTemplate.findOne({ where: { type } });
if (!template) {
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) => {
transporter.sendMail(
@@ -65,4 +88,4 @@ const sendEmail = async ({ to, type, data = {} }) => {
const ping = () => transporter.verify();
module.exports = { sendEmail, ping };
module.exports = { sendEmail, ping };
+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 };