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);