diff --git a/controllers/admin/achievements.controller.js b/controllers/admin/achievements.controller.js new file mode 100644 index 0000000..6f074c5 --- /dev/null +++ b/controllers/admin/achievements.controller.js @@ -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); + } +}; diff --git a/controllers/admin/advertisements.controller.js b/controllers/admin/advertisements.controller.js index b2d8794..56a8cdb 100644 --- a/controllers/admin/advertisements.controller.js +++ b/controllers/admin/advertisements.controller.js @@ -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 */ } diff --git a/controllers/admin/assets.controller.js b/controllers/admin/assets.controller.js index 5ebe057..92453cd 100644 --- a/controllers/admin/assets.controller.js +++ b/controllers/admin/assets.controller.js @@ -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, diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 7fabed1..e2e33a4 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -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 }); diff --git a/controllers/admin/documentation/advertisements.md b/controllers/admin/documentation/advertisements.md index dde36f8..f0c8a87 100644 --- a/controllers/admin/documentation/advertisements.md +++ b/controllers/admin/documentation/advertisements.md @@ -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"] + } } ``` diff --git a/controllers/admin/email_broadcasts.controller.js b/controllers/admin/email_broadcasts.controller.js new file mode 100644 index 0000000..660fcab --- /dev/null +++ b/controllers/admin/email_broadcasts.controller.js @@ -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); + } +}; diff --git a/controllers/admin/email_templates.controller.js b/controllers/admin/email_templates.controller.js new file mode 100644 index 0000000..c4023e9 --- /dev/null +++ b/controllers/admin/email_templates.controller.js @@ -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); + } +}; diff --git a/controllers/admin/media.controller.js b/controllers/admin/media.controller.js index 8c042a5..26bc0ec 100644 --- a/controllers/admin/media.controller.js +++ b/controllers/admin/media.controller.js @@ -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) { diff --git a/controllers/admin/notificationBroadcasts.controller.js b/controllers/admin/notificationBroadcasts.controller.js new file mode 100644 index 0000000..e840c2c --- /dev/null +++ b/controllers/admin/notificationBroadcasts.controller.js @@ -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); + } +}; diff --git a/controllers/admin/notificationSettings.controller.js b/controllers/admin/notificationSettings.controller.js new file mode 100644 index 0000000..3a14c10 --- /dev/null +++ b/controllers/admin/notificationSettings.controller.js @@ -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); + } +}; diff --git a/controllers/admin/plan_prices.controller.js b/controllers/admin/plan_prices.controller.js deleted file mode 100644 index 095e693..0000000 --- a/controllers/admin/plan_prices.controller.js +++ /dev/null @@ -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); -}; diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index b9a9238..195250f 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -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(); diff --git a/controllers/admin/tiers.controller.js b/controllers/admin/tiers.controller.js index d983525..00b7fd5 100644 --- a/controllers/admin/tiers.controller.js +++ b/controllers/admin/tiers.controller.js @@ -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( diff --git a/controllers/admin/units.controller.js b/controllers/admin/units.controller.js index 415b9e2..199588f 100644 --- a/controllers/admin/units.controller.js +++ b/controllers/admin/units.controller.js @@ -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); diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index cf04c33..3480236 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -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, }, diff --git a/controllers/client/advertisements.controller.js b/controllers/client/advertisements.controller.js index 30b2578..da02bac 100644 --- a/controllers/client/advertisements.controller.js +++ b/controllers/client/advertisements.controller.js @@ -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 diff --git a/controllers/client/certificate.controller.js b/controllers/client/certificate.controller.js index 8ed4502..cd23268 100644 --- a/controllers/client/certificate.controller.js +++ b/controllers/client/certificate.controller.js @@ -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(' ') : ''; diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index ad6b90f..ee18a23 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -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)); } diff --git a/controllers/client/media.controller.js b/controllers/client/media.controller.js index c3d4adb..1fc37ef 100644 --- a/controllers/client/media.controller.js +++ b/controllers/client/media.controller.js @@ -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) ──────────────── diff --git a/controllers/client/notification.controller.js b/controllers/client/notification.controller.js index bd27db7..4dc0086 100644 --- a/controllers/client/notification.controller.js +++ b/controllers/client/notification.controller.js @@ -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 }; diff --git a/controllers/client/profile.controller.js b/controllers/client/profile.controller.js index 27ad8cd..28c6772 100644 --- a/controllers/client/profile.controller.js +++ b/controllers/client/profile.controller.js @@ -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) => { diff --git a/controllers/client/task.controller.js b/controllers/client/task.controller.js index 1c4e4fc..0387f73 100644 --- a/controllers/client/task.controller.js +++ b/controllers/client/task.controller.js @@ -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); } -}; \ No newline at end of file +}; diff --git a/controllers/client/task_progress.controller.js b/controllers/client/task_progress.controller.js index 357120c..17624d0 100644 --- a/controllers/client/task_progress.controller.js +++ b/controllers/client/task_progress.controller.js @@ -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); // } -// }; \ No newline at end of file +// }; diff --git a/controllers/client/tiers.controller.js b/controllers/client/tiers.controller.js index 74de059..89dcd73 100644 --- a/controllers/client/tiers.controller.js +++ b/controllers/client/tiers.controller.js @@ -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); diff --git a/cron/admin.cron.js b/cron/admin.cron.js index 494f478..06d8045 100644 --- a/cron/admin.cron.js +++ b/cron/admin.cron.js @@ -14,8 +14,16 @@ * That's the only wiring required — server.js never needs to * change when admin-side jobs are added/removed. * + * taskOverdue is settings-backed (see cronRegistry.util.js) — + * its schedule/enabled state lives in cron_notification_settings + * and is configurable from /admin/notifications/settings without + * a restart. liftExpiredBans is not notification-related, so it + * stays on a plain hardcoded schedule. + * * Currently registered: - * - taskOverdue (cron/jobs/taskOverdue.cron.js) + * - taskOverdue (cron/jobs/task_overdue.cron.js) — settings-backed + * - liftExpiredBans (cron/jobs/lift_expired_bans.cron.js) + * - dispatchEmailBroadcasts (cron/jobs/dispatch_email_broadcasts.cron.js) * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 17, 2026 @@ -23,25 +31,34 @@ const cron = require('node-cron'); const taskOverdue = require('./jobs/task_overdue.cron'); const liftExpiredBans = require('./jobs/lift_expired_bans.cron'); +const dispatchEmailBroadcasts = require('./jobs/dispatch_email_broadcasts.cron'); +const { startSettingsBackedJobs } = require('./cronRegistry.util'); // ─── Registry — add future admin-side cron jobs here ───────────────────────── -const jobs = [ +const settingsBackedJobs = [ taskOverdue, +]; + +// Plain hardcoded-schedule jobs (not tied to any notification setting). +const plainJobs = [ liftExpiredBans, + dispatchEmailBroadcasts, ]; // ─── Boot all registered admin-side jobs ────────────────────────────────────── -function startAdminCronJobs() { - const registered = []; - jobs.forEach(({ name, schedule, run }) => { - if (!cron.validate(schedule)) { - console.error(`[CRON][ADMIN] Invalid schedule for "${name}": "${schedule}" — skipped.`); - return; +async function startAdminCronJobs() { + const registered = await startSettingsBackedJobs(settingsBackedJobs, 'ADMIN'); + + for (const job of plainJobs) { + if (!cron.validate(job.schedule)) { + console.error(`[CRON][ADMIN] Invalid schedule for "${job.name}": "${job.schedule}" — skipped.`); + continue; } - cron.schedule(schedule, run); - registered.push({ name, scope: 'ADMIN', schedule }); - }); + cron.schedule(job.schedule, job.run); + registered.push({ name: job.name, scope: 'ADMIN', schedule: job.schedule }); + } + return registered; } -module.exports = { startAdminCronJobs }; \ No newline at end of file +module.exports = { startAdminCronJobs }; diff --git a/cron/client.cron.js b/cron/client.cron.js index 07dca8b..b696264 100644 --- a/cron/client.cron.js +++ b/cron/client.cron.js @@ -5,6 +5,11 @@ * Same shape as admin.cron.js — each job module exports * { name, schedule, run }, listed in the `jobs` array below. * + * All three are settings-backed (see cronRegistry.util.js) — + * schedule/enabled state lives in cron_notification_settings + * and is configurable from /admin/notifications/settings + * without a restart. + * * Currently registered: * - userNotifications (cron/jobs/user_notifications.cron.js) * - issueCertificates (cron/jobs/issue_certificates.cron.js) @@ -13,10 +18,10 @@ * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 17, 2026 ***********************************************************************************************************************************************************************/ -const cron = require('node-cron'); const userNotifications = require('./jobs/user_notifications.cron'); const issueCertificates = require('./jobs/issue_certificates.cron'); const expireUserTiers = require('./jobs/expire_user_tiers.cron'); +const { startSettingsBackedJobs } = require('./cronRegistry.util'); // ─── Registry — add future client-side cron jobs here ──────────────────────── const jobs = [ @@ -26,17 +31,8 @@ const jobs = [ ]; // ─── Boot all registered client-side jobs ───────────────────────────────────── -function startClientCronJobs() { - const registered = []; - jobs.forEach(({ name, schedule, run }) => { - if (!cron.validate(schedule)) { - console.error(`[CRON][CLIENT] Invalid schedule for "${name}": "${schedule}" — skipped.`); - return; - } - cron.schedule(schedule, run); - registered.push({ name, scope: 'CLIENT', schedule }); - }); - return registered; +async function startClientCronJobs() { + return startSettingsBackedJobs(jobs, 'CLIENT'); } -module.exports = { startClientCronJobs }; \ No newline at end of file +module.exports = { startClientCronJobs }; diff --git a/cron/cronRegistry.util.js b/cron/cronRegistry.util.js new file mode 100644 index 0000000..61b5560 --- /dev/null +++ b/cron/cronRegistry.util.js @@ -0,0 +1,82 @@ +/*********************************************************************************************************************************************************************** + * File Name : cronRegistry.util.js + * Type : Utility + * Description : Shared machinery for settings-backed cron jobs (the 4 jobs + * that emit notifications and are configurable from + * /admin/notifications/settings). Not every cron job in the + * app goes through this — jobs with no notification tied to + * them (e.g. lift_expired_bans) keep using node-cron directly. + * + * startSettingsBackedJobs() reads each job's schedule from + * cron_notification_settings (falling back to — and seeding — + * the job's own hardcoded default on first boot), then keeps + * a live reference to the scheduled task so it can be swapped + * out later via rescheduleJob() without a server restart. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 2, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const cron = require('node-cron'); +const CronNotificationSetting = require('../models/notifications/cron_notification_setting.mdl'); + +// job_name -> { task: ScheduledTask, run: fn } +const runningTasks = new Map(); + +// CockroachDB can't run Sequelize's findOrCreate() — it wraps the insert in a +// pg_temp PL/pgSQL function to atomically catch unique_violation, which +// CockroachDB rejects ("cannot create user-defined functions under a temporary +// schema"). Plain findOne-then-create sidesteps it; the race window (two boots +// racing to seed the same job_name) is a non-issue here — jobs are seeded once. +async function getOrCreateSetting(jobName, defaultSchedule) { + let row = await CronNotificationSetting.findOne({ where: { job_name: jobName } }); + if (row) return row; + + try { + row = await CronNotificationSetting.create({ job_name: jobName, enabled: true, schedule: defaultSchedule }); + } catch (err) { + row = await CronNotificationSetting.findOne({ where: { job_name: jobName } }); + if (!row) throw err; + } + return row; +} + +async function startSettingsBackedJobs(jobs, scopeLabel) { + const registered = []; + + for (const { name, schedule: defaultSchedule, run } of jobs) { + let schedule = defaultSchedule; + try { + const settings = await getOrCreateSetting(name, defaultSchedule); + schedule = settings.schedule || defaultSchedule; + } catch (err) { + console.error(`[CRON][${scopeLabel}] Failed to load settings for "${name}", using hardcoded default:`, err); + } + + if (!cron.validate(schedule)) { + console.error(`[CRON][${scopeLabel}] Invalid schedule for "${name}": "${schedule}" — skipped.`); + continue; + } + + const task = cron.schedule(schedule, run); + runningTasks.set(name, { task, run }); + registered.push({ name, scope: scopeLabel, schedule }); + } + + return registered; +} + +// Live-swap a running job's schedule — used by notificationSettings.controller.js +// after an admin picks a new preset. No server restart required. +function rescheduleJob(jobName, newSchedule) { + const entry = runningTasks.get(jobName); + if (!entry) throw new Error(`No running cron task found for "${jobName}".`); + if (!cron.validate(newSchedule)) throw new Error(`Invalid cron schedule: "${newSchedule}".`); + + entry.task.stop(); + const task = cron.schedule(newSchedule, entry.run); + runningTasks.set(jobName, { task, run: entry.run }); +} + +module.exports = { startSettingsBackedJobs, rescheduleJob, getOrCreateSetting }; diff --git a/cron/jobs/dispatch_email_broadcasts.cron.js b/cron/jobs/dispatch_email_broadcasts.cron.js new file mode 100644 index 0000000..38d289c --- /dev/null +++ b/cron/jobs/dispatch_email_broadcasts.cron.js @@ -0,0 +1,110 @@ +/*********************************************************************************************************************************************************************** + * File Name : dispatch_email_broadcasts.cron.js + * Type : Cron Job + * Description : Sends real, paced SMTP email for queued email_broadcasts — + * the actual delivery half of controllers/admin/email_broadcasts + * .controller.js's createEmailBroadcast(), which only ever + * enqueues rows and returns immediately. + * + * Every tick, picks up to BATCH_SIZE 'pending' recipient rows + * (oldest broadcast first, FIFO within it) and sends them one + * at a time with a short delay between each — this is the "one + * by one, not a blocking for-loop in the API request" behavior: + * it's fine to block *here* because nothing is waiting on an + * HTTP response, and the delay keeps us well under Gmail SMTP's + * practical sustained-send pacing. + * + * Resumable by construction — if the process restarts mid- + * broadcast, the next tick just keeps consuming whatever rows + * are still 'pending'. Single-instance only: this does not use + * row-level locking, so running more than one app instance + * would let two ticks grab the same batch. Fine for the + * current single-process deployment; would need SELECT ... FOR + * UPDATE SKIP LOCKED before scaling horizontally. + * + * Schedule : Every minute ("* * * * *"). Registered by cron/admin.cron.js. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 3, 2026 + ***********************************************************************************************************************************************************************/ +const mdl_EmailBroadcast = require('../../models/email_templates/email_broadcast.mdl'); +const mdl_EmailBroadcastRecipient = require('../../models/email_templates/email_broadcast_recipient.mdl'); +const mdl_EmailTemplate = require('../../models/email_templates/email_templates.mdl'); +const { sendEmail } = require('../../services/email.service'); + +const BATCH_SIZE = 25; +const DELAY_MS = 600; // pacing between individual sends — keeps us well under Gmail's throttling threshold + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function run() { + try { + const recipients = await mdl_EmailBroadcastRecipient.findAll({ + where: { status: 'pending' }, + include: [{ + model: mdl_EmailBroadcast, + as: 'broadcast', + where: { status: ['queued', 'sending'] }, + include: [{ model: mdl_EmailTemplate, as: 'template' }], + }], + order: [['email_broadcast_id', 'ASC'], ['email_broadcast_recipient_id', 'ASC']], + limit: BATCH_SIZE, + }); + + if (!recipients.length) return; + + const touchedBroadcastIds = new Set(); + + for (const recipient of recipients) { + const broadcast = recipient.broadcast; + const template = broadcast?.template; + + if (!broadcast || !template) { + await recipient.update({ status: 'failed', error: 'Broadcast or template no longer exists.', sent_at: new Date() }); + continue; + } + + if (broadcast.status === 'queued') { + await broadcast.update({ status: 'sending', started_at: broadcast.started_at ?? new Date() }); + } + touchedBroadcastIds.add(broadcast.email_broadcast_id); + + try { + await sendEmail({ + to: recipient.email, + type: template.type, + data: { name: recipient.name || 'there', email: recipient.email }, + }); + await recipient.update({ status: 'sent', sent_at: new Date() }); + await broadcast.increment('sent_count'); + } catch (err) { + await recipient.update({ status: 'failed', error: err.message, sent_at: new Date() }); + await broadcast.increment('failed_count'); + console.error('[CRON][EMAIL BROADCAST] Send failed:', recipient.email, err.message); + } + + await delay(DELAY_MS); + } + + // Close out any broadcast that has no pending recipients left. + for (const broadcastId of touchedBroadcastIds) { + const remaining = await mdl_EmailBroadcastRecipient.count({ where: { email_broadcast_id: broadcastId, status: 'pending' } }); + if (remaining === 0) { + await mdl_EmailBroadcast.update( + { status: 'completed', completed_at: new Date() }, + { where: { email_broadcast_id: broadcastId, status: ['queued', 'sending'] } } + ); + } + } + + console.log(`[CRON][EMAIL BROADCAST] Processed ${recipients.length} recipient(s) across ${touchedBroadcastIds.size} broadcast(s).`); + } catch (err) { + console.error('[CRON][EMAIL BROADCAST] Failed:', err); + } +} + +module.exports = { + name: 'dispatchEmailBroadcasts', + schedule: '* * * * *', + run, +}; diff --git a/cron/jobs/expire_user_tiers.cron.js b/cron/jobs/expire_user_tiers.cron.js index 7aaa2cc..f55d405 100644 --- a/cron/jobs/expire_user_tiers.cron.js +++ b/cron/jobs/expire_user_tiers.cron.js @@ -27,6 +27,7 @@ const { Op } = require('sequelize'); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); +const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); require('../../models/tiers/tier.associations'); @@ -43,7 +44,7 @@ async function run() { include: [{ model: mdl_TierPlans, as: 'plan', - attributes: ['label', 'tier'], + attributes: ['plan_id', 'label', 'tier'], required: false, }], attributes: ['tier_id', 'user_id', 'tier'], @@ -69,20 +70,25 @@ async function run() { } // ── 3. Send in-app notifications (one per affected user) ────────────────── - const notifications = expired.map((t) => - NOTIFICATION_REGISTRY.tier_expired.build({ - tier: t.tier, - label: t.plan?.label ?? null, - }) - ).map((payload, i) => ({ - user_id: expired[i].user_id, - ...payload, - })); + // Status flip above always happens — only this step is skippable via settings. + const settings = await CronNotificationSetting.findOne({ where: { job_name: 'expireUserTiers' } }); + if (!settings || settings.enabled) { + const notifications = expired.map((t) => + NOTIFICATION_REGISTRY.tier_expired.build({ + tier: t.tier, + label: t.plan?.label ?? null, + planId: t.plan?.plan_id ?? null, + }) + ).map((payload, i) => ({ + user_id: expired[i].user_id, + ...payload, + })); - try { - await UserNotification.bulkCreate(notifications, { ignoreDuplicates: true }); - } catch (err) { - console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err); + try { + await UserNotification.bulkCreate(notifications, { ignoreDuplicates: true }); + } catch (err) { + console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err); + } } console.log(`[CRON][EXPIRE TIERS] Expired ${expired.length} tier(s) for ${new Set(expired.map((t) => t.user_id)).size} user(s).`); diff --git a/cron/jobs/issue_certificates.cron.js b/cron/jobs/issue_certificates.cron.js index ebd9bda..684c53d 100644 --- a/cron/jobs/issue_certificates.cron.js +++ b/cron/jobs/issue_certificates.cron.js @@ -9,8 +9,11 @@ * For each ready row it: * 1. Grants the course_completed_ achievement (the key * MyCertificates / Profile use to display certificate cards). - * 2. Sends a 'certificate_issued' UserNotification. - * 3. Marks the row processed_at = NOW() so it never fires again. + * 2. Persists the certificate record (cert_no/ref_no) via + * services/certificate-record.service.js, so course.certificate + * is populated immediately instead of only on first PDF download. + * 3. Sends a 'certificate_issued' UserNotification. + * 4. Marks the row processed_at = NOW() so it never fires again. * * Safety pattern: processed_at is set only after both step 1 and * step 2 succeed. If the process restarts mid-run the row will be @@ -28,9 +31,15 @@ const { Op } = require('sequelize'); const PendingCertificate = require('../../models/courses/pending_certificate.mdl'); const mdl_Achievements = require('../../models/users/achievements.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); +const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); +const { ensureCertificateRecord } = require('../../services/certificate-record.service'); async function run() { + // Certificate/achievement issuance always happens — only the notification step is skippable. + const settings = await CronNotificationSetting.findOne({ where: { job_name: 'issueCertificates' } }); + const notificationsEnabled = !settings || settings.enabled; + // ── 1. Fetch all rows ready to process ──────────────────────────────────── let rows; try { @@ -69,16 +78,23 @@ async function run() { }); } - // ── 3. Send certificate_issued notification ──────────────────────── - await UserNotification.create({ - user_id, - ...NOTIFICATION_REGISTRY.certificate_issued.build({ - courseTitle: course_title ?? '', - courseUuid: course_uuid, - }), - }); + // ── 3. Persist the actual certificate record (cert_no/ref_no) so + // course.certificate is populated immediately, instead of only + // lazily on first PDF download ──────────────────────────────── + await ensureCertificateRecord({ userId: user_id, courseId: row.course_id }); - // ── 4. Mark row processed ───────────────────────────────────────── + // ── 4. Send certificate_issued notification ───────────────────────── + if (notificationsEnabled) { + await UserNotification.create({ + user_id, + ...NOTIFICATION_REGISTRY.certificate_issued.build({ + courseTitle: course_title ?? '', + courseUuid: course_uuid, + }), + }); + } + + // ── 5. Mark row processed ───────────────────────────────────────── await PendingCertificate.update( { processed_at: new Date() }, { where: { pending_id } } diff --git a/cron/jobs/task_overdue.cron.js b/cron/jobs/task_overdue.cron.js index 7e1162d..7db14a3 100644 --- a/cron/jobs/task_overdue.cron.js +++ b/cron/jobs/task_overdue.cron.js @@ -25,6 +25,7 @@ const { Op } = require('sequelize'); const { Task } = require('../../models/task/task.mdl'); const AdminNotification = require('../../models/notifications/admin_notification.mdl'); +const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); // ─── The actual sweep ──────────────────────────────────────────────────────── @@ -52,7 +53,11 @@ async function run() { console.log(`[CRON][TASK OVERDUE] Marked ${affectedCount} task(s) as overdue.`); // ── 2. Secondary: admin notification — isolated, never blocks step 1 ───── + // Skippable via /admin/notifications/settings — the status flip above always happens either way. try { + const settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskOverdue' } }); + if (settings && !settings.enabled) return; + await AdminNotification.create( NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount }) ); diff --git a/cron/jobs/user_notifications.cron.js b/cron/jobs/user_notifications.cron.js index 59a324a..0de856f 100644 --- a/cron/jobs/user_notifications.cron.js +++ b/cron/jobs/user_notifications.cron.js @@ -24,11 +24,16 @@ const { Op, QueryTypes } = require('sequelize'); const sequelize = require('../../config/db.config'); const { Task } = require('../../models/task/task.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); +const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback async function run() { + // Entire job exists to emit this notification — skippable via /admin/notifications/settings. + const settings = await CronNotificationSetting.findOne({ where: { job_name: 'userNotifications' } }); + if (settings && !settings.enabled) return; + // ── 1. Find tasks that flipped to overdue in the last 65 minutes ────────── let recentlyOverdue; try { diff --git a/data/achievements.data.js b/data/achievements.data.js index fced63b..70cd8ef 100644 --- a/data/achievements.data.js +++ b/data/achievements.data.js @@ -1,119 +1,19 @@ /*********************************************************************************************************************************************************************** * File Name: achievements.data.js * Type of Program: Data - * Description: Static registry of all available achievements (badges and milestones). - * Add a new achievement here — no other code changes needed. - * - * Available keys: - * Badge: - * early_access — registered before Dec 31, 2026 - * premium_first_time — first premium tier purchase - * exclusive_first_time — first exclusive tier purchase - * Milestone: - * first_course_completed — completed first course - * courses_completed_5 — completed 5 courses - * courses_completed_10 — completed 10 courses - * perfect_quiz_score — perfect score on a quiz - * profile_completed — filled out full profile - * first_referral — referred a user + * Description: Static config for the achievement system that isn't part of the + * admin-managed catalog. The achievement catalog itself (keys, type, + * label, description, icon) now lives in the achievement_definitions + * table — see models/users/achievement_definitions.mdl.js and + * controllers/admin/achievements.controller.js for CRUD. * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 19, 2026 ***********************************************************************************************************************************************************************/ 'use strict'; -// ─── Early Access cutoff ────────────────────────────────────────────────────── - const EARLY_ACCESS_CUTOFF = new Date('2026-12-31T23:59:59Z'); -// ─── Achievement Registry ───────────────────────────────────────────────────── -// To add a new achievement — add an entry here. No other code changes needed. -// -// trigger categories (for documentation only, not enforced): -// auth — registration / login events -// tier — subscription purchase events -// course — course / lesson / quiz events -// profile — profile completion events -// social — referral / community events - -const ACHIEVEMENT_REGISTRY = { - - // ── Auth ─────────────────────────────────────────────────────────────────── - early_access: { - key: 'early_access', - type: 'badge', - label: 'Early Access', - description: 'Registered during the Philproperties beta period (before Dec 31, 2026).', - trigger: 'auth', - }, - - // ── Tier ─────────────────────────────────────────────────────────────────── - premium_first_time: { - key: 'premium_first_time', - type: 'badge', - label: 'Premium Member', - description: 'Purchased a Premium tier plan for the first time.', - trigger: 'tier', - }, - exclusive_first_time: { - key: 'exclusive_first_time', - type: 'badge', - label: 'Exclusive Member', - description: 'Purchased an Exclusive tier plan for the first time.', - trigger: 'tier', - }, - - // ── Course ───────────────────────────────────────────────────────────────── - first_course_completed: { - key: 'first_course_completed', - type: 'milestone', - label: 'First Course Completed', - description: 'Completed your very first course on Philproperties.', - trigger: 'course', - }, - courses_completed_5: { - key: 'courses_completed_5', - type: 'milestone', - label: 'Learning Streak', - description: 'Completed 5 courses.', - trigger: 'course', - }, - courses_completed_10: { - key: 'courses_completed_10', - type: 'milestone', - label: 'Knowledge Builder', - description: 'Completed 10 courses.', - trigger: 'course', - }, - perfect_quiz_score: { - key: 'perfect_quiz_score', - type: 'milestone', - label: 'Perfect Score', - description: 'Achieved a perfect score on a quiz.', - trigger: 'course', - }, - - // ── Profile ──────────────────────────────────────────────────────────────── - profile_completed: { - key: 'profile_completed', - type: 'milestone', - label: 'Profile Complete', - description: 'Filled out all personal profile information.', - trigger: 'profile', - }, - - // ── Social ───────────────────────────────────────────────────────────────── - first_referral: { - key: 'first_referral', - type: 'milestone', - label: 'Referral Champion', - description: 'Successfully referred a user to Philproperties.', - trigger: 'social', - }, - -}; - module.exports = { EARLY_ACCESS_CUTOFF, - ACHIEVEMENT_REGISTRY, }; diff --git a/data/cronPresets.data.js b/data/cronPresets.data.js new file mode 100644 index 0000000..ffcf129 --- /dev/null +++ b/data/cronPresets.data.js @@ -0,0 +1,28 @@ +/*********************************************************************************************************************************************************************** + * File Name: cronPresets.data.js + * Type of Program: Data + * Description: Friendly schedule presets for admin-configurable notification + * crons. The UI only ever offers these six options — no raw cron + * expressions are accepted from the client, so `updateSetting` + * in notificationSettings.controller.js validates against this map. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 2, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const CRON_PRESETS = { + every_minute: '* * * * *', + every_5_min: '*/5 * * * *', + every_15_min: '*/15 * * * *', + hourly: '0 * * * *', + every_6_hours: '0 */6 * * *', + daily: '0 0 * * *', +}; + +// Reverse lookup — cron string -> preset key (used to label a job's current schedule) +const CRON_PRESET_BY_EXPRESSION = Object.fromEntries( + Object.entries(CRON_PRESETS).map(([key, expr]) => [expr, key]) +); + +module.exports = { CRON_PRESETS, CRON_PRESET_BY_EXPRESSION }; diff --git a/data/email_body.data.js b/data/email_body.data.js deleted file mode 100644 index 75849f1..0000000 --- a/data/email_body.data.js +++ /dev/null @@ -1,105 +0,0 @@ -const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;'; - -const wrap = (body) => ` - - - ${body.trim()} -

-

Regards,
Philproperties IT Team

-

This is an automated message from STARR System. Please do not reply.

- -`.trim(); - -export const emailTemplates = { - OTP: ({ otp, expiryMinutes = 10 }) => ({ - subject: "Email OTP Verification - STARR System", - html: wrap(` -

Dear User,

-

Please use the One-Time Password (OTP) below to verify your email address. This code is valid for ${expiryMinutes} minutes.

-

${otp}

-

For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.

- `), - }), - - WELCOME: ({ name }) => ({ - subject: "Welcome to STARR System", - html: wrap(` -

Dear ${name},

-

We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.

-

You may now access your dashboard and begin using the available services.

-

We look forward to supporting you.

- `), - }), - - PASSWORD_CHANGED: () => ({ - subject: "Password Update Confirmation - STARR System", - html: wrap(` -

Dear User,

-

This is to confirm that your account password has been successfully changed.

-

If you did not perform this action, please reset your password immediately or contact support.

-

For your security, we recommend using a strong and unique password.

- `), - }), - - ADDED_TO_GROUP: ({ groupName }) => ({ - subject: "Group Assignment Notification - STARR System", - html: wrap(` -

Dear User,

-

You have been assigned to the group ${groupName} in the STARR System.

-

This assignment grants you access to shared resources and collaboration tools within the group.

-

Please log in to your account to view group details.

- `), - }), - - TASK_ASSIGNED: ({ taskTitle, dueDate }) => ({ - subject: "New Task Assignment - STARR System", - html: wrap(` -

Dear User,

-

You have been assigned a new task in the STARR System.

- - - -
Task${taskTitle}
Due Date${dueDate}
-

Kindly ensure completion within the specified timeframe.

- `), - }), - - BAN_LIFTED: ({ name, email, date }) => ({ - subject: "Account Suspension Lifted - STARR System", - html: wrap(` -

Dear ${name},

-

We are writing to inform you that the suspension on your account (${email}) has been lifted effective ${date}.

-

You may now log in and resume access to all services within the STARR System.

-

If you have any concerns, please do not hesitate to contact your administrator.

- `), - }), - - BANNED: ({ name, email, date, reason, ban_type }) => ({ - subject: "Account Suspension Notice - STARR System", - html: wrap(` -

Dear ${name},

-

Your account (${email}) has been ${ban_type === 'permanent' ? 'permanently' : 'temporarily'} suspended from the STARR System effective ${date}.

- - - -
Reason${reason}
Duration${ban_type === 'permanent' ? 'Permanent' : 'Temporary'}
-

During this period, access to all system services has been revoked.${ban_type === 'permanent' ? '' : ' This suspension may be lifted upon review by the administrator.'}

-

If you believe this was made in error, please contact your administrator.

- `), - }), - - ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({ - subject: "Your Staff Account Has Been Created - STARR System", - html: wrap(` -

Dear ${name},

-

Your staff account has been successfully created in the STARR System. Below are your login credentials:

- - - -
Email${email}
Password${password}
-

This temporary password is valid for ${expiryHours} hours. You will be required to change it upon first login.

-

If you did not request this account or believe this was created in error, please contact your administrator immediately to have it deactivated.

-

For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.

- `), - }), -}; diff --git a/data/email_template_enrichers.data.js b/data/email_template_enrichers.data.js new file mode 100644 index 0000000..8d19191 --- /dev/null +++ b/data/email_template_enrichers.data.js @@ -0,0 +1,28 @@ +/*********************************************************************************************************************************************************************** + * File Name: email_template_enrichers.data.js + * Type of Program: Data / Registry + * Description: Admin-edited templates are plain HTML — no conditionals or + * expressions allowed. Any type that used to branch on data in + * JS (e.g. BANNED's permanent/temporary wording) gets that branch + * precomputed here into flat placeholder keys BEFORE substitution, + * so the stored HTML only ever needs straight {{key}} swaps. + * Adding a new derived placeholder for a type should only require + * adding/editing one entry here. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 3, 2026 + ***********************************************************************************************************************************************************************/ + +const ENRICHERS = { + OTP: (data) => ({ expiryMinutes: 10, ...data }), + + BANNED: (data) => ({ + ...data, + duration_word: data.ban_type === 'permanent' ? 'permanently' : 'temporarily', + duration_label: data.ban_type === 'permanent' ? 'Permanent' : 'Temporary', + suspension_note: data.ban_type === 'permanent' ? '' : ' This suspension may be lifted upon review by the administrator.', + }), +}; + +const enrichEmailData = (type, data = {}) => (ENRICHERS[type] ? ENRICHERS[type](data) : data); + +module.exports = { enrichEmailData }; diff --git a/data/notifications.data.js b/data/notifications.data.js index d4494ab..75c0547 100644 --- a/data/notifications.data.js +++ b/data/notifications.data.js @@ -20,9 +20,10 @@ * * Current types: * Admin : task_overdue, user_registration, nogrp_user_registered - * User : user_task_overdue, achievement, course_unlocked, + * User : task_requirements_updated, user_task_overdue, achievement, course_unlocked, * course_completed, certificate_issued, task_reminder, announcement, * nogrp_welcome, tier_expired + * Both : broadcast (admin-composed, sent via notification_broadcasts CRUD) * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 19, 2026 @@ -87,6 +88,20 @@ const NOTIFICATION_REGISTRY = { // ───────────────────────────────────────────────────────────────────────── // ── Task ────────────────────────────────────────────────────────────────── + task_requirements_updated: { + type: 'task', + scope: 'user', + trigger: 'event', + build({ taskName, taskListId = null, groupId = null }) { + return { + type: 'task', + title: 'Task Updated', + message: `The requirements for "${taskName}" have been updated by your administrator.`, + data: { taskName, taskListId, groupId }, + }; + }, + }, + user_task_overdue: { type: 'task', scope: 'user', @@ -106,12 +121,12 @@ const NOTIFICATION_REGISTRY = { type: 'task', scope: 'user', trigger: 'cron', - build({ taskName, deadline }) { + build({ taskName, deadline, taskListId = null, groupId = null }) { return { type: 'task', title: 'Task Deadline Approaching', message: `"${taskName}" is due on ${fmtDate(deadline)}.`, - data: { taskName, deadline }, + data: { taskName, deadline, taskListId, groupId }, }; }, }, @@ -136,12 +151,12 @@ const NOTIFICATION_REGISTRY = { type: 'course', scope: 'user', trigger: 'event', - build({ courseTitle }) { + build({ courseTitle, courseUuid = null }) { return { type: 'course', title: 'New Course Available', message: `"${courseTitle}" has been added to your learning library.`, - data: { courseTitle }, + data: { courseTitle, courseUuid }, }; }, }, @@ -150,12 +165,12 @@ const NOTIFICATION_REGISTRY = { type: 'course', scope: 'user', trigger: 'event', - build({ courseTitle }) { + build({ courseTitle, courseUuid = null }) { return { type: 'course', title: 'Course Completed', message: `Great job! You've completed "${courseTitle}". Your certificate will be issued within the next hour.`, - data: { courseTitle }, + data: { courseTitle, courseUuid }, }; }, }, @@ -179,7 +194,7 @@ const NOTIFICATION_REGISTRY = { type: 'announcement', scope: 'user', trigger: 'event', - build({ groupName, groupCode, accType }) { + build({ groupName, groupCode, accType, groupId = null }) { const greeting = accType === 'admin' ? 'Welcome, Administrator!' : accType === 'staff' @@ -189,7 +204,7 @@ const NOTIFICATION_REGISTRY = { type: 'announcement', title: 'Welcome to Philproperties', message: groupName ? `${greeting} You have been added to ${groupName}.` : greeting, - data: { groupName, groupCode, accType }, + data: { groupName, groupCode, accType, groupId }, }; }, }, @@ -214,12 +229,12 @@ const NOTIFICATION_REGISTRY = { type: 'assessment', scope: 'user', trigger: 'event', - build({ assessmentTitle, courseTitle }) { + build({ assessmentTitle, courseTitle, courseUuid = null }) { return { type: 'assessment', title: 'Assessment Updated', message: `The administrator has updated the "${assessmentTitle || 'Course Assessment'}" in "${courseTitle || 'your course'}". Your current session is still valid — continue where you left off.`, - data: { assessmentTitle, courseTitle }, + data: { assessmentTitle, courseTitle, courseUuid }, }; }, }, @@ -239,17 +254,32 @@ const NOTIFICATION_REGISTRY = { }, }, + // ── Broadcast (admin-composed, manual) ─────────────────────────────────── + broadcast: { + type: 'announcement', + scope: 'both', + trigger: 'manual', + build({ title, message, targetType = null, targetId = null, groupId = null }) { + return { + type: 'announcement', + title, + message, + data: { targetType, targetId, groupId }, + }; + }, + }, + // ── Tier ────────────────────────────────────────────────────────────────── tier_expired: { type: 'tier_expired', scope: 'user', trigger: 'cron', - build({ tier, label }) { + build({ tier, label, planId = null }) { return { type: 'tier_expired', title: 'Subscription Expired', message: `Your ${label ?? tier} plan has expired. Renew to keep access.`, - data: { tier, label }, + data: { tier, label, planId }, }; }, }, diff --git a/database/migrations/20260101000069-add-preferred-currency-to-users.js b/database/migrations/20260101000069-add-preferred-currency-to-users.js deleted file mode 100644 index 0d288fc..0000000 --- a/database/migrations/20260101000069-add-preferred-currency-to-users.js +++ /dev/null @@ -1,15 +0,0 @@ -'use strict'; - -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.addColumn('users', 'preferred_currency', { - type: Sequelize.CHAR(3), - allowNull: false, - defaultValue: 'USD', - }); - }, - - async down(queryInterface) { - await queryInterface.removeColumn('users', 'preferred_currency'); - }, -}; diff --git a/database/migrations/20260101000070-create-plan-prices.js b/database/migrations/20260101000070-create-plan-prices.js deleted file mode 100644 index 5e72e7b..0000000 --- a/database/migrations/20260101000070-create-plan-prices.js +++ /dev/null @@ -1,34 +0,0 @@ -'use strict'; - -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.createTable('plan_prices', { - price_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, - plan_id: { - type: Sequelize.BIGINT, - allowNull: false, - references: { model: 'tier_plans', key: 'plan_id' }, - onUpdate: 'CASCADE', - onDelete: 'CASCADE', - }, - currency: { type: Sequelize.CHAR(3), allowNull: false }, - price: { type: Sequelize.DECIMAL(10, 2), allowNull: false }, - createdAt: { type: Sequelize.DATE, allowNull: false }, - updatedAt: { type: Sequelize.DATE, allowNull: false }, - }); - - await queryInterface.addConstraint('plan_prices', { - fields: ['plan_id', 'currency'], - type: 'unique', - name: 'uq_plan_prices_plan_currency', - }); - - await queryInterface.addIndex('plan_prices', ['plan_id'], { - name: 'idx_plan_prices_plan_id', - }); - }, - - async down(queryInterface) { - await queryInterface.dropTable('plan_prices'); - }, -}; diff --git a/database/migrations/20260702000001-create-notification-broadcasts.js b/database/migrations/20260702000001-create-notification-broadcasts.js new file mode 100644 index 0000000..17ec596 --- /dev/null +++ b/database/migrations/20260702000001-create-notification-broadcasts.js @@ -0,0 +1,31 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('notification_broadcasts', { + broadcast_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true }, + title: { type: Sequelize.STRING(255), allowNull: false }, + message: { type: Sequelize.TEXT, allowNull: false }, + audience: { type: Sequelize.ENUM('admin', 'user', 'both'), allowNull: false }, + status: { type: Sequelize.ENUM('draft', 'sent', 'archived'), allowNull: false, defaultValue: 'draft' }, + sent_at: { type: Sequelize.DATE, allowNull: true }, + recipient_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + createdBy: { type: Sequelize.BIGINT, allowNull: true }, + updatedBy: { type: Sequelize.BIGINT, allowNull: true }, + deletedBy: { type: Sequelize.BIGINT, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + deletedAt: { type: Sequelize.DATE, allowNull: true }, + }); + + await queryInterface.addIndex('notification_broadcasts', ['uuid']); + await queryInterface.addIndex('notification_broadcasts', ['status']); + await queryInterface.addIndex('notification_broadcasts', ['audience']); + await queryInterface.addIndex('notification_broadcasts', ['deletedAt']); + }, + + async down(queryInterface) { + await queryInterface.dropTable('notification_broadcasts'); + }, +}; diff --git a/database/migrations/20260702000002-add-target-to-notification-broadcasts.js b/database/migrations/20260702000002-add-target-to-notification-broadcasts.js new file mode 100644 index 0000000..1ed20bd --- /dev/null +++ b/database/migrations/20260702000002-add-target-to-notification-broadcasts.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + // CockroachDB: add enum values in place, then rename the column. + // Type is named 'notification_broadcast_audience' (matches the explicit CREATE TYPE + // used when this table was first created — not Sequelize's default enum__name). + await queryInterface.sequelize.query(`ALTER TYPE public.notification_broadcast_audience ADD VALUE IF NOT EXISTS 'task_list'`); + await queryInterface.sequelize.query(`ALTER TYPE public.notification_broadcast_audience ADD VALUE IF NOT EXISTS 'course'`); + await queryInterface.sequelize.query(`ALTER TYPE public.notification_broadcast_audience ADD VALUE IF NOT EXISTS 'tier_plan'`); + + await queryInterface.renameColumn('notification_broadcasts', 'audience', 'target_type'); + + await queryInterface.addColumn('notification_broadcasts', 'target_id', { + type: Sequelize.STRING(64), + allowNull: true, + }); + + await queryInterface.removeIndex('notification_broadcasts', ['audience']).catch(() => {}); + await queryInterface.addIndex('notification_broadcasts', ['target_type']); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('notification_broadcasts', 'target_id'); + await queryInterface.renameColumn('notification_broadcasts', 'target_type', 'audience'); + }, +}; diff --git a/database/migrations/20260702000003-create-cron-notification-settings.js b/database/migrations/20260702000003-create-cron-notification-settings.js new file mode 100644 index 0000000..9aedecd --- /dev/null +++ b/database/migrations/20260702000003-create-cron-notification-settings.js @@ -0,0 +1,18 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('cron_notification_settings', { + job_name: { type: Sequelize.STRING(64), primaryKey: true }, + enabled: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true }, + schedule: { type: Sequelize.STRING(20), allowNull: false }, + updatedBy: { type: Sequelize.BIGINT, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('cron_notification_settings'); + }, +}; diff --git a/database/migrations/20260702000004-add-placement-to-advertisements.js b/database/migrations/20260702000004-add-placement-to-advertisements.js new file mode 100644 index 0000000..21b0f9a --- /dev/null +++ b/database/migrations/20260702000004-add-placement-to-advertisements.js @@ -0,0 +1,28 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('advertisements', 'placement', { + type: Sequelize.STRING(100), + allowNull: true, + }); + + await queryInterface.addIndex('advertisements', ['placement']); + + // Backfill the only two placements that were actually wired up before this + // migration (Dashboard hero + popup). Any existing banner/sidebar rows are + // left with placement = NULL — dev data, an admin reassigns them via the + // edit form once the new Page/Position picker ships. + await queryInterface.sequelize.query(` + UPDATE advertisements SET placement = 'dashboard.hero' WHERE type = 'hero' AND placement IS NULL + `); + await queryInterface.sequelize.query(` + UPDATE advertisements SET placement = 'dashboard.popup' WHERE type = 'popup' AND placement IS NULL + `); + }, + + async down(queryInterface) { + await queryInterface.removeIndex('advertisements', ['placement']).catch(() => {}); + await queryInterface.removeColumn('advertisements', 'placement'); + }, +}; diff --git a/database/migrations/20260703000001-create-achievement-definitions.js b/database/migrations/20260703000001-create-achievement-definitions.js new file mode 100644 index 0000000..98bb023 --- /dev/null +++ b/database/migrations/20260703000001-create-achievement-definitions.js @@ -0,0 +1,40 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('achievement_definitions', { + achievement_definition_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + key: { type: Sequelize.STRING(100), allowNull: false, unique: true }, + type: { type: Sequelize.ENUM('badge', 'milestone'), allowNull: false, defaultValue: 'badge' }, + label: { type: Sequelize.STRING(255), allowNull: false }, + description: { type: Sequelize.TEXT, allowNull: true }, + icon: { type: Sequelize.STRING(50), allowNull: true }, + trigger: { type: Sequelize.STRING(30), allowNull: true }, + is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true }, + is_system: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + // Seed the 9 built-in keys that services/achievements.service.js's trigger + // functions reference by name — these are marked is_system so they can't be + // deleted/renamed from the admin CRUD. + await queryInterface.sequelize.query(` + INSERT INTO achievement_definitions (key, type, label, description, icon, trigger, is_active, is_system, "createdAt", "updatedAt") + VALUES + ('early_access', 'badge', 'Early Access', 'Registered during the Philproperties beta period (before Dec 31, 2026).', 'Star', 'auth', true, true, NOW(), NOW()), + ('premium_first_time', 'badge', 'Premium Member', 'Purchased a Premium tier plan for the first time.', 'BadgeCheck', 'tier', true, true, NOW(), NOW()), + ('exclusive_first_time', 'badge', 'Exclusive Member', 'Purchased an Exclusive tier plan for the first time.', 'Medal', 'tier', true, true, NOW(), NOW()), + ('first_course_completed', 'milestone', 'First Course Completed', 'Completed your very first course on Philproperties.', 'BookOpen', 'course', true, true, NOW(), NOW()), + ('courses_completed_5', 'milestone', 'Learning Streak', 'Completed 5 courses.', 'Flame', 'course', true, true, NOW(), NOW()), + ('courses_completed_10', 'milestone', 'Knowledge Builder', 'Completed 10 courses.', 'Zap', 'course', true, true, NOW(), NOW()), + ('perfect_quiz_score', 'milestone', 'Perfect Score', 'Achieved a perfect score on a quiz.', 'Target', 'course', true, true, NOW(), NOW()), + ('profile_completed', 'milestone', 'Profile Complete', 'Filled out all personal profile information.', 'Shield', 'profile', true, true, NOW(), NOW()), + ('first_referral', 'milestone', 'Referral Champion', 'Successfully referred a user to Philproperties.', 'Award', 'social', true, true, NOW(), NOW()) + `); + }, + + async down(queryInterface) { + await queryInterface.dropTable('achievement_definitions'); + }, +}; diff --git a/database/migrations/20260703000002-add-icon-to-achievements.js b/database/migrations/20260703000002-add-icon-to-achievements.js new file mode 100644 index 0000000..1387ab7 --- /dev/null +++ b/database/migrations/20260703000002-add-icon-to-achievements.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('achievements', 'icon', { + type: Sequelize.STRING(50), + allowNull: true, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('achievements', 'icon'); + }, +}; diff --git a/database/migrations/20260703000003-create-email-templates.js b/database/migrations/20260703000003-create-email-templates.js new file mode 100644 index 0000000..d8122f3 --- /dev/null +++ b/database/migrations/20260703000003-create-email-templates.js @@ -0,0 +1,114 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('email_templates', { + email_template_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + type: { type: Sequelize.STRING(100), allowNull: false, unique: true }, + label: { type: Sequelize.STRING(150), allowNull: false }, + subject: { type: Sequelize.STRING(255), allowNull: false }, + html_body: { type: Sequelize.TEXT, allowNull: false }, + is_system: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;'; + const now = new Date(); + + // Seed the 8 built-in types that services/email.service.js's sendEmail() + // looks up by name — these are marked is_system so they can't be + // deleted/renamed from the admin CRUD. Body content only (no / + // wrapper) — the header/footer layout stays fixed in code, not admin-editable. + await queryInterface.bulkInsert('email_templates', [ + { + type: 'OTP', label: 'OTP Verification', is_system: true, + subject: 'Email OTP Verification - STARR System', + html_body: `

Dear User,

+

Please use the One-Time Password (OTP) below to verify your email address. This code is valid for {{expiryMinutes}} minutes.

+

{{otp}}

+

For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.

`, + createdAt: now, updatedAt: now, + }, + { + type: 'WELCOME', label: 'Welcome Email', is_system: true, + subject: 'Welcome to STARR System', + html_body: `

Dear {{name}},

+

We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.

+

You may now access your dashboard and begin using the available services.

+

We look forward to supporting you.

`, + createdAt: now, updatedAt: now, + }, + { + type: 'PASSWORD_CHANGED', label: 'Password Changed', is_system: true, + subject: 'Password Update Confirmation - STARR System', + html_body: `

Dear User,

+

This is to confirm that your account password has been successfully changed.

+

If you did not perform this action, please reset your password immediately or contact support.

+

For your security, we recommend using a strong and unique password.

`, + createdAt: now, updatedAt: now, + }, + { + type: 'ADDED_TO_GROUP', label: 'Added to Group', is_system: true, + subject: 'Group Assignment Notification - STARR System', + html_body: `

Dear User,

+

You have been assigned to the group {{groupName}} in the STARR System.

+

This assignment grants you access to shared resources and collaboration tools within the group.

+

Please log in to your account to view group details.

`, + createdAt: now, updatedAt: now, + }, + { + type: 'TASK_ASSIGNED', label: 'Task Assigned', is_system: true, + subject: 'New Task Assignment - STARR System', + html_body: `

Dear User,

+

You have been assigned a new task in the STARR System.

+
+ + +
Task{{taskTitle}}
Due Date{{dueDate}}
+

Kindly ensure completion within the specified timeframe.

`, + createdAt: now, updatedAt: now, + }, + { + type: 'BAN_LIFTED', label: 'Ban Lifted', is_system: true, + subject: 'Account Suspension Lifted - STARR System', + html_body: `

Dear {{name}},

+

We are writing to inform you that the suspension on your account ({{email}}) has been lifted effective {{date}}.

+

You may now log in and resume access to all services within the STARR System.

+

If you have any concerns, please do not hesitate to contact your administrator.

`, + createdAt: now, updatedAt: now, + }, + { + type: 'BANNED', label: 'Account Banned', is_system: true, + subject: 'Account Suspension Notice - STARR System', + html_body: `

Dear {{name}},

+

Your account ({{email}}) has been {{duration_word}} suspended from the STARR System effective {{date}}.

+ + + +
Reason{{reason}}
Duration{{duration_label}}
+

During this period, access to all system services has been revoked.{{suspension_note}}

+

If you believe this was made in error, please contact your administrator.

`, + createdAt: now, updatedAt: now, + }, + { + type: 'ADD_STAFF', label: 'Staff Account Created', is_system: true, + subject: 'Your Staff Account Has Been Created - STARR System', + html_body: `

Dear {{name}},

+

Your staff account has been successfully created in the STARR System. Below are your login credentials:

+ + + +
Email{{email}}
Password{{password}}
+

This temporary password is valid for {{expiryHours}} hours. You will be required to change it upon first login.

+

If you did not request this account or believe this was created in error, please contact your administrator immediately to have it deactivated.

+

For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.

`, + createdAt: now, updatedAt: now, + }, + ]); + }, + + async down(queryInterface) { + await queryInterface.dropTable('email_templates'); + }, +}; diff --git a/database/migrations/20260703000004-add-category-to-email-templates.js b/database/migrations/20260703000004-add-category-to-email-templates.js new file mode 100644 index 0000000..b9e44b2 --- /dev/null +++ b/database/migrations/20260703000004-add-category-to-email-templates.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('email_templates', 'category', { + type: Sequelize.ENUM('announcement', 'advertisement', 'system', 'other'), + allowNull: false, + defaultValue: 'other', + }); + + // The 8 built-in (is_system) templates are all account/auth notifications. + await queryInterface.sequelize.query(` + UPDATE email_templates SET category = 'system' WHERE is_system = true; + `); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('email_templates', 'category'); + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_templates_category";`); + }, +}; diff --git a/database/migrations/20260703000005-add-publish-workflow-to-email-templates.js b/database/migrations/20260703000005-add-publish-workflow-to-email-templates.js new file mode 100644 index 0000000..86a8ffa --- /dev/null +++ b/database/migrations/20260703000005-add-publish-workflow-to-email-templates.js @@ -0,0 +1,46 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + // Live `subject`/`html_body` become nullable — a brand-new template can + // now exist as a pure draft with no live content at all until it's sent. + await queryInterface.changeColumn('email_templates', 'subject', { + type: Sequelize.STRING(255), allowNull: true, + }); + await queryInterface.changeColumn('email_templates', 'html_body', { + type: Sequelize.TEXT, allowNull: true, + }); + + await queryInterface.addColumn('email_templates', 'status', { + type: Sequelize.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft', + }); + + // Edits to a 'sent' template land here first — sendEmail() only ever reads + // the live subject/html_body columns, never these — until an admin + // explicitly re-sends (publishes), the pending edit can't affect real mail. + await queryInterface.addColumn('email_templates', 'draft_subject', { + type: Sequelize.STRING(255), allowNull: true, + }); + await queryInterface.addColumn('email_templates', 'draft_html_body', { + type: Sequelize.TEXT, allowNull: true, + }); + await queryInterface.addColumn('email_templates', 'last_sent_at', { + type: Sequelize.DATE, allowNull: true, + }); + + // Every row created before this migration already had required subject/ + // html_body — meaning it was already "operating" in the old single-state + // world. Backfill them all as sent. + await queryInterface.sequelize.query(` + UPDATE email_templates SET status = 'sent', last_sent_at = "updatedAt"; + `); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('email_templates', 'last_sent_at'); + await queryInterface.removeColumn('email_templates', 'draft_html_body'); + await queryInterface.removeColumn('email_templates', 'draft_subject'); + await queryInterface.removeColumn('email_templates', 'status'); + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_templates_status";`); + }, +}; diff --git a/database/migrations/20260703000006-create-email-broadcasts.js b/database/migrations/20260703000006-create-email-broadcasts.js new file mode 100644 index 0000000..6b4f649 --- /dev/null +++ b/database/migrations/20260703000006-create-email-broadcasts.js @@ -0,0 +1,47 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('email_broadcasts', { + email_broadcast_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + email_template_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'email_templates', key: 'email_template_id' } }, + target_type: { type: Sequelize.ENUM('admin', 'user', 'both', 'task_list', 'course', 'tier_plan'), allowNull: false }, + target_id: { type: Sequelize.STRING(64), allowNull: true }, + status: { type: Sequelize.ENUM('queued', 'sending', 'completed', 'canceled'), allowNull: false, defaultValue: 'queued' }, + total_recipients: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + sent_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + failed_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + started_at: { type: Sequelize.DATE, allowNull: true }, + completed_at: { type: Sequelize.DATE, allowNull: true }, + createdBy: { type: Sequelize.BIGINT, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + await queryInterface.addIndex('email_broadcasts', ['status']); + + await queryInterface.createTable('email_broadcast_recipients', { + email_broadcast_recipient_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + email_broadcast_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'email_broadcasts', key: 'email_broadcast_id' } }, + user_id: { type: Sequelize.BIGINT, allowNull: true }, + email: { type: Sequelize.STRING(255), allowNull: false }, + name: { type: Sequelize.STRING(255), allowNull: true }, + status: { type: Sequelize.ENUM('pending', 'sent', 'failed'), allowNull: false, defaultValue: 'pending' }, + error: { type: Sequelize.TEXT, allowNull: true }, + sent_at: { type: Sequelize.DATE, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + // The cron's core query: "give me the next pending batch for a broadcast". + await queryInterface.addIndex('email_broadcast_recipients', ['email_broadcast_id', 'status']); + }, + + async down(queryInterface) { + await queryInterface.dropTable('email_broadcast_recipients'); + await queryInterface.dropTable('email_broadcasts'); + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_broadcast_recipients_status";`); + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_broadcasts_status";`); + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_broadcasts_target_type";`); + }, +}; diff --git a/database/migrations/20260703000007-add-markdown-source-to-email-templates.js b/database/migrations/20260703000007-add-markdown-source-to-email-templates.js new file mode 100644 index 0000000..78d36f0 --- /dev/null +++ b/database/migrations/20260703000007-add-markdown-source-to-email-templates.js @@ -0,0 +1,24 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + // The admin editor now authors in Markdown (converted client-side to the + // HTML that subject/html_body already require) — these columns retain the + // original Markdown purely so reopening a template for editing shows the + // human-readable source again instead of the compiled HTML. Templates + // created before this migration (all 8 system templates included) have + // no Markdown source — html_body/draft_html_body remain hand-written HTML + // for them, and the editor falls back to editing that directly. + await queryInterface.addColumn('email_templates', 'body_markdown', { + type: Sequelize.TEXT, allowNull: true, + }); + await queryInterface.addColumn('email_templates', 'draft_body_markdown', { + type: Sequelize.TEXT, allowNull: true, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('email_templates', 'draft_body_markdown'); + await queryInterface.removeColumn('email_templates', 'body_markdown'); + }, +}; diff --git a/models/advertisements/advertisements.mdl.js b/models/advertisements/advertisements.mdl.js index 414eafd..5de2038 100644 --- a/models/advertisements/advertisements.mdl.js +++ b/models/advertisements/advertisements.mdl.js @@ -11,21 +11,31 @@ const Advertisement = sequelize.define("Advertisement", { uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true }, // ─── Identification / placement ────────────────────────────────────────── + // placement is the source of truth for "where" (a registry key, see + // advertisements.placements.js); type is denormalized from it on every + // write (applyAdvertisementFields) and describes "what it looks like". + placement: { + type: DataTypes.STRING(100), + allowNull: true, + filterable: true, + label: "Placement", order: 1 + }, type: { type: DataTypes.ENUM("hero", "banner", "popup", "sidebar"), allowNull: false, - label: "Type", order: 1 + filterable: true, + label: "Format", order: 2 }, status: { type: DataTypes.ENUM("draft", "active", "scheduled", "expired", "archived"), allowNull: false, - defaultValue: "draft", label: "Status", order: 2 + defaultValue: "draft", label: "Status", order: 3 }, // ─── Content ────────────────────────────────────────────────────────────── - badge_label: { type: DataTypes.STRING(100), label: "Badge Label", order: 3 }, - headline: { type: DataTypes.STRING(255), label: "Headline", order: 4 }, - description: { type: DataTypes.TEXT, label: "Description", order: 5 }, + badge_label: { type: DataTypes.STRING(100), label: "Badge Label", order: 4 }, + headline: { type: DataTypes.STRING(255), label: "Headline", order: 5 }, + description: { type: DataTypes.TEXT, label: "Description", order: 6 }, // ─── Media ──────────────────────────────────────────────────────────────── image_url: { type: DataTypes.STRING(512), label: "Image URL", order: 0, hidden: true }, @@ -33,19 +43,19 @@ const Advertisement = sequelize.define("Advertisement", { // ─── Calls-to-action ────────────────────────────────────────────────────── // [{ label, link }, ...] — 0-2 entries depending on type - ctas: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "CTAs", order: 6 }, + ctas: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "CTAs", order: 7 }, // ─── Scheduling ─────────────────────────────────────────────────────────── - start_date: { type: DataTypes.DATE, label: "Start Date", order: 7 }, - end_date: { type: DataTypes.DATE, label: "End Date", order: 8 }, + start_date: { type: DataTypes.DATE, label: "Start Date", order: 8 }, + end_date: { type: DataTypes.DATE, label: "End Date", order: 9 }, // ─── Display behavior ───────────────────────────────────────────────────── - order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: "order", label: "Order", order: 9 }, - is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Active", order: 10 }, - size: { type: DataTypes.ENUM("sm", "md", "lg"), allowNull: true, label: "Size", order: 11 }, // banner-only, ignored by other types + order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: "order", label: "Order", order: 10 }, + is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Active", order: 11 }, + size: { type: DataTypes.ENUM("sm", "md", "lg"), allowNull: true, label: "Size", order: 12 }, // banner-only, ignored by other types // ─── Metrics ────────────────────────────────────────────────────────────── - click_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Clicks", order: 12, hidden: true }, + click_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Clicks", order: 13, hidden: true }, // ─── Audit trails ───────────────────────────────────────────────────────── createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, @@ -58,6 +68,7 @@ const Advertisement = sequelize.define("Advertisement", { indexes: [ { fields: ["uuid"] }, { fields: ["type"] }, + { fields: ["placement"] }, { fields: ["status"] }, { fields: ["is_active"] }, { fields: ["deletedAt"] }, diff --git a/models/advertisements/advertisements.placements.js b/models/advertisements/advertisements.placements.js new file mode 100644 index 0000000..3b0432e --- /dev/null +++ b/models/advertisements/advertisements.placements.js @@ -0,0 +1,31 @@ +// models/advertisements/advertisements.placements.js +// +// Declarative registry of every ad placement in the client app. Each entry +// is a self-contained "slot" — a page + position pair — that determines the +// visual format (hero/banner/popup/sidebar) automatically. Adding a new +// placement should only ever require adding one entry here (and wiring the +// corresponding client page to fetch/render it) — nothing else in this file +// should need to change. +// +// `key` is what's stored on advertisements.placement. `format` is what gets +// denormalized onto advertisements.type on write (see applyAdvertisementFields +// in controllers/admin/advertisements.controller.js) — type is never accepted +// from the client once a placement is set. + +const PLACEMENTS = [ + { key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" }, + { key: "dashboard.popup", format: "popup", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Popup (on load)" }, + { key: "course_list.banner", format: "banner", page: "course_list", pageLabel: "Courses", slotLabel: "Banner (above course grid)" }, + { key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" }, + { key: "course_details.sidebar", format: "sidebar", page: "course_details", pageLabel: "Course Details", slotLabel: "Sidebar (beside course content)" }, + { key: "plans.banner", format: "banner", page: "plans", pageLabel: "Plans", slotLabel: "Banner (above plan cards)" }, +]; + +const PLACEMENT_MAP = Object.fromEntries(PLACEMENTS.map((p) => [p.key, p])); +const PLACEMENT_KEYS = PLACEMENTS.map((p) => p.key); + +function getFormatForPlacement(key) { + return PLACEMENT_MAP[key]?.format ?? null; +} + +module.exports = { PLACEMENTS, PLACEMENT_MAP, PLACEMENT_KEYS, getFormatForPlacement }; diff --git a/models/courses/pending_certificate.mdl.js b/models/courses/pending_certificate.mdl.js index bb1a9db..1fb61fb 100644 --- a/models/courses/pending_certificate.mdl.js +++ b/models/courses/pending_certificate.mdl.js @@ -1,10 +1,10 @@ /*********************************************************************************************************************************************************************** * File Name: pending_certificate.mdl.js * Type of Program: Model - * Description: Holds certificates queued for issuance after a 45-minute delay + * Description: Holds certificates queued for issuance after a 5-minute delay * following a passed course assessment. The cron job - * (cron/jobs/issue_certificates.cron.js) polls this table every - * 5 minutes and processes rows where issue_at <= NOW(). + * (cron/jobs/issue_certificates.cron.js) runs hourly on the hour + * and processes rows where issue_at <= NOW(). * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 24, 2026 diff --git a/models/email_templates/email_broadcast.mdl.js b/models/email_templates/email_broadcast.mdl.js new file mode 100644 index 0000000..8a8ebe4 --- /dev/null +++ b/models/email_templates/email_broadcast.mdl.js @@ -0,0 +1,38 @@ +/*********************************************************************************************************************************************************************** + * File Name: email_broadcast.mdl.js + * Type of Program: Model + * Description: One "send this email template to this audience" job. The API + * only ever creates this row + its email_broadcast_recipients + * rows (see controllers/admin/email_broadcasts.controller.js) — + * actual SMTP sending happens later, paced, in + * cron/jobs/dispatch_email_broadcasts.cron.js. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 3, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); +const mdl_EmailTemplate = require('./email_templates.mdl'); +const mdl_Users = require('../users/users.mdl'); + +const mdl_EmailBroadcast = sequelize.define('EmailBroadcast', { + email_broadcast_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 }, + email_template_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Template', order: 1 }, + target_type: { type: DataTypes.ENUM('admin', 'user', 'both', 'task_list', 'course', 'tier_plan'), allowNull: false, label: 'Target', order: 2, filterable: true }, + target_id: { type: DataTypes.STRING(64), allowNull: true, label: 'Target ID', order: 3 }, + status: { type: DataTypes.ENUM('queued', 'sending', 'completed', 'canceled'), allowNull: false, defaultValue: 'queued', label: 'Status', order: 4, filterable: true }, + total_recipients: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: 'Total', order: 5 }, + sent_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: 'Sent', order: 6 }, + failed_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: 'Failed', order: 7 }, + started_at: { type: DataTypes.DATE, allowNull: true, label: 'Started', order: 8 }, + completed_at: { type: DataTypes.DATE, allowNull: true, label: 'Completed', order: 9 }, + createdBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Created By' }, +}, { + tableName: 'email_broadcasts', + timestamps: true, + paranoid: false, +}); + +mdl_EmailBroadcast.belongsTo(mdl_EmailTemplate, { as: 'template', foreignKey: 'email_template_id' }); +mdl_EmailBroadcast.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' }); + +module.exports = mdl_EmailBroadcast; diff --git a/models/email_templates/email_broadcast_recipient.mdl.js b/models/email_templates/email_broadcast_recipient.mdl.js new file mode 100644 index 0000000..86e2e08 --- /dev/null +++ b/models/email_templates/email_broadcast_recipient.mdl.js @@ -0,0 +1,33 @@ +/*********************************************************************************************************************************************************************** + * File Name: email_broadcast_recipient.mdl.js + * Type of Program: Model + * Description: The outbox — one row per recipient of an email_broadcasts job. + * `email` is snapshotted at enqueue time so a later change to the + * user's account email doesn't affect an in-flight broadcast. + * cron/jobs/dispatch_email_broadcasts.cron.js is the only writer + * of `status`/`error`/`sent_at` after creation. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 3, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); +const mdl_EmailBroadcast = require('./email_broadcast.mdl'); + +const mdl_EmailBroadcastRecipient = sequelize.define('EmailBroadcastRecipient', { + email_broadcast_recipient_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 }, + email_broadcast_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Broadcast', order: 1 }, + user_id: { type: DataTypes.BIGINT, allowNull: true, label: 'User', order: 2 }, + email: { type: DataTypes.STRING(255), allowNull: false, label: 'Email', order: 3 }, + name: { type: DataTypes.STRING(255), allowNull: true, label: 'Name', order: 4 }, + status: { type: DataTypes.ENUM('pending', 'sent', 'failed'), allowNull: false, defaultValue: 'pending', label: 'Status', order: 5, filterable: true }, + error: { type: DataTypes.TEXT, allowNull: true, label: 'Error', order: 6 }, + sent_at: { type: DataTypes.DATE, allowNull: true, label: 'Sent At', order: 7 }, +}, { + tableName: 'email_broadcast_recipients', + timestamps: true, + paranoid: false, +}); + +mdl_EmailBroadcastRecipient.belongsTo(mdl_EmailBroadcast, { as: 'broadcast', foreignKey: 'email_broadcast_id' }); + +module.exports = mdl_EmailBroadcastRecipient; diff --git a/models/email_templates/email_templates.mdl.js b/models/email_templates/email_templates.mdl.js new file mode 100644 index 0000000..574484f --- /dev/null +++ b/models/email_templates/email_templates.mdl.js @@ -0,0 +1,49 @@ +/*********************************************************************************************************************************************************************** + * File Name: email_templates.mdl.js + * Type of Program: Model + * Description: Admin-managed catalog of email templates (subject + HTML body). + * Replaces the old static emailTemplates map in data/email_body.data.js. + * `is_system` rows are the built-in types referenced by name in + * services/email.service.js's sendEmail() callers — protected + * from deletion/type-rename by the admin controller. The outer + * layout (header/footer/signature) is NOT stored here — it stays + * fixed in services/email.service.js and is never admin-editable. + * + * Publish workflow: `subject`/`html_body` are the LIVE content — + * the only columns services/email.service.js's sendEmail() ever + * reads. Editing a 'sent' template writes to `draft_subject`/ + * `draft_html_body` instead, leaving live content (and therefore + * real outgoing mail) untouched until an admin explicitly + * publishes again (see controllers/admin/email_templates.controller.js). + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 3, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const mdl_EmailTemplate = sequelize.define('EmailTemplate', { + email_template_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 }, + type: { type: DataTypes.STRING(100), allowNull: false, unique: true, label: 'Type', hidden: false, order: 1, filterable: true }, + label: { type: DataTypes.STRING(150), allowNull: false, label: 'Label', hidden: false, order: 2, filterable: true }, + category: { type: DataTypes.ENUM('announcement', 'advertisement', 'system', 'other'), allowNull: false, defaultValue: 'other', label: 'Category', hidden: false, order: 3, filterable: true }, + status: { type: DataTypes.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft', label: 'Status', hidden: false, order: 4, filterable: true }, + subject: { type: DataTypes.STRING(255), allowNull: true, label: 'Subject', hidden: false, order: 5, filterable: false }, + html_body: { type: DataTypes.TEXT, allowNull: true, label: 'HTML Body', hidden: false, order: 6, filterable: false }, + // Markdown source for the live/draft HTML above — editor convenience only, + // never read by services/email.service.js. Null for templates authored + // before Markdown support (including all 8 system templates), which keep + // editing html_body/draft_html_body directly. + body_markdown: { type: DataTypes.TEXT, allowNull: true, label: 'Body (Markdown)', hidden: false, order: 6.5, filterable: false }, + draft_subject: { type: DataTypes.STRING(255), allowNull: true, label: 'Draft Subject', hidden: false, order: 7, filterable: false }, + draft_html_body: { type: DataTypes.TEXT, allowNull: true, label: 'Draft Body', hidden: false, order: 8, filterable: false }, + draft_body_markdown: { type: DataTypes.TEXT, allowNull: true, label: 'Draft Body (Markdown)', hidden: false, order: 8.5, filterable: false }, + last_sent_at: { type: DataTypes.DATE, allowNull: true, label: 'Last Sent', hidden: false, order: 9, filterable: false }, + is_system: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'System', hidden: false, order: 10, filterable: true }, +}, { + tableName: 'email_templates', + timestamps: true, + paranoid: false, +}); + +module.exports = mdl_EmailTemplate; diff --git a/models/notifications/cron_notification_setting.mdl.js b/models/notifications/cron_notification_setting.mdl.js new file mode 100644 index 0000000..31e3ddc --- /dev/null +++ b/models/notifications/cron_notification_setting.mdl.js @@ -0,0 +1,15 @@ +// models/notifications/cron_notification_setting.mdl.js +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); + +const CronNotificationSetting = sequelize.define("CronNotificationSetting", { + job_name: { type: DataTypes.STRING(64), primaryKey: true, label: "Job" }, + enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Enabled" }, + schedule: { type: DataTypes.STRING(20), allowNull: false, label: "Schedule" }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, +}, { + tableName: "cron_notification_settings", + timestamps: true, +}); + +module.exports = CronNotificationSetting; diff --git a/models/notifications/notification_broadcast.attributes.js b/models/notifications/notification_broadcast.attributes.js new file mode 100644 index 0000000..b64b312 --- /dev/null +++ b/models/notifications/notification_broadcast.attributes.js @@ -0,0 +1,24 @@ +// models/notifications/notification_broadcast.attributes.js + +// ─── Exclude sets ───────────────────────────────────────────────────────────── + +const excludeAttributes = []; + +// Admins see everything +const adminExclude = [ + ...excludeAttributes, +]; + +// ─── JSONB schemas ────────────────────────────────────────────────────────── +// No JSONB columns on this model. +const jsonbSchemas = {}; + +// ─── Computed attributes ────────────────────────────────────────────────────── +const computedAttributes = []; + +module.exports = { + excludeAttributes, + adminExclude, + jsonbSchemas, + computedAttributes, +}; diff --git a/models/notifications/notification_broadcast.mdl.js b/models/notifications/notification_broadcast.mdl.js new file mode 100644 index 0000000..6f020c5 --- /dev/null +++ b/models/notifications/notification_broadcast.mdl.js @@ -0,0 +1,54 @@ +// models/notifications/notification_broadcast.mdl.js +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); +const mdl_Users = require("../users/users.mdl"); + +const NotificationBroadcast = sequelize.define("NotificationBroadcast", { + + // ─── Identity ───────────────────────────────────────────────────────────── + broadcast_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Broadcast ID", order: 0, hidden: true }, + uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true }, + + // ─── Content ────────────────────────────────────────────────────────────── + title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 }, + message: { type: DataTypes.TEXT, allowNull: false, label: "Message", order: 2 }, + + // ─── Targeting ──────────────────────────────────────────────────────────── + target_type: { + type: DataTypes.ENUM("admin", "user", "both", "task_list", "course", "tier_plan"), + allowNull: false, + label: "Target", order: 3 + }, + // Holds a task list UUID, course UUID, or tier plan ID (stringified) — only + // set when target_type is 'task_list' / 'course' / 'tier_plan'. + target_id: { type: DataTypes.STRING(64), allowNull: true, label: "Target ID", order: 3.5 }, + + // ─── Lifecycle ──────────────────────────────────────────────────────────── + status: { + type: DataTypes.ENUM("draft", "sent", "archived"), + allowNull: false, + defaultValue: "draft", label: "Status", order: 4 + }, + sent_at: { type: DataTypes.DATE, allowNull: true, label: "Sent At", order: 5 }, + recipient_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Recipients", order: 6 }, + + // ─── Audit trails ───────────────────────────────────────────────────────── + createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, +}, { + tableName: "notification_broadcasts", + timestamps: true, // createdAt, updatedAt + paranoid: true, + indexes: [ + { fields: ["uuid"] }, + { fields: ["status"] }, + { fields: ["target_type"] }, + { fields: ["deletedAt"] }, + ], +}); + +NotificationBroadcast.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); +NotificationBroadcast.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); + +module.exports = NotificationBroadcast; diff --git a/models/tiers/payments.attributes.js b/models/tiers/payments.attributes.js index 8232436..6482c39 100644 --- a/models/tiers/payments.attributes.js +++ b/models/tiers/payments.attributes.js @@ -8,6 +8,9 @@ const excludeAttributes = ['provider_payload']; const jsonbSchemas = {}; -const computedAttributes = []; +const computedAttributes = [ + { key: 'user_full_name', label: 'Full Name', type: 'text', order: 1, filterable: false, literal: `("user"."personal_info"->'name'->>'full_name')` }, + { key: 'user.email', label: 'Email Address', type: 'text', order: 2, filterable: false }, +]; module.exports = { excludeAttributes, jsonbSchemas, computedAttributes }; \ No newline at end of file diff --git a/models/tiers/payments.mdl.js b/models/tiers/payments.mdl.js index 53a3e75..f5de951 100644 --- a/models/tiers/payments.mdl.js +++ b/models/tiers/payments.mdl.js @@ -25,13 +25,13 @@ const mdl_Payments = sequelize.define('Payment', { plan_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Plan ID', hidden: true, order: 2, filterable: true }, tier_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Tier ID', hidden: true, order: 3, filterable: true }, status: { type: DataTypes.ENUM('pending', 'completed', 'failed', 'cancelled', 'expired', 'refunded'), allowNull: false, defaultValue: 'pending', label: 'Status', hidden: false, order: 4, filterable: true }, - amount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Amount', hidden: false, order: 5, filterable: false }, + amount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Amount', hidden: false, order: 3, filterable: false }, currency: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'USD', label: 'Currency', hidden: false, order: 6, filterable: true }, promo_code: { type: DataTypes.STRING(50), allowNull: true, label: 'Promo Code', hidden: false, order: 7, filterable: true }, discount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, defaultValue: 0.00, label: 'Discount', hidden: false, order: 8, filterable: false }, provider: { type: DataTypes.STRING(50), allowNull: false, defaultValue: 'paypal', label: 'Provider', hidden: false, order: 9, filterable: true }, provider_payload: { type: DataTypes.JSONB, allowNull: true, defaultValue: {}, label: 'Provider Payload', hidden: true, order: 10, filterable: false }, - paid_at: { type: DataTypes.DATE, allowNull: true, label: 'Paid At', hidden: false, order: 11, filterable: false }, + paid_at: { type: DataTypes.DATE, allowNull: true, label: 'Paid At', hidden: false, order: 5, filterable: false }, createdBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Created By' }, updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Updated By' }, deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Deleted By' }, diff --git a/models/tiers/plan_prices.mdl.js b/models/tiers/plan_prices.mdl.js deleted file mode 100644 index 01cdfef..0000000 --- a/models/tiers/plan_prices.mdl.js +++ /dev/null @@ -1,26 +0,0 @@ -/*********************************************************************************************************************************************************************** - * File Name: plan_prices.mdl.js - * Type of Program: Model - * Description: Admin-managed localized price overrides for tier plans. - * One row per (plan_id, currency) pair. When a user's preferred_currency - * matches a row here, the override price is shown instead of the base price. - * Author: Kenneth Obsequio (@lash0000) - * Date Created: Jun. 29, 2026 - ***********************************************************************************************************************************************************************/ -'use strict'; - -const { DataTypes } = require('sequelize'); -const sequelize = require('../../config/db.config'); - -const mdl_PlanPrices = sequelize.define('PlanPrice', { - price_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, - plan_id: { type: DataTypes.BIGINT, allowNull: false }, - currency: { type: DataTypes.CHAR(3), allowNull: false, label: 'Currency' }, - price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' }, -}, { - tableName: 'plan_prices', - timestamps: true, - paranoid: false, -}); - -module.exports = mdl_PlanPrices; diff --git a/models/tiers/tier.associations.js b/models/tiers/tier.associations.js index 0d43f2d..d5262a5 100644 --- a/models/tiers/tier.associations.js +++ b/models/tiers/tier.associations.js @@ -5,7 +5,6 @@ const mdl_UserTiers = require('./user_tiers.mdl'); const mdl_Payments = require('./payments.mdl'); const mdl_PlanCourses = require('./plan_courses.mdl'); const mdl_PlanPolicies = require('./plan_policies.mdl'); -const mdl_PlanPrices = require('./plan_prices.mdl'); const mdl_SystemBadges = require('../system_badges/system_badges.mdl'); const Asset = require('../assets/assets.mdl'); const { Course } = require('../courses/courses.mdl'); @@ -54,10 +53,6 @@ mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers' mdl_TierPlans.hasOne(mdl_PlanPolicies, { foreignKey: 'plan_id', as: 'policy' }); mdl_PlanPolicies.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); -// ─── Plan ↔ Localized Prices ────────────────────────────────────────────────── -mdl_TierPlans.hasMany(mdl_PlanPrices, { foreignKey: 'plan_id', as: 'prices' }); -mdl_PlanPrices.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); - // ─── SystemBadge → Asset ───────────────────────────────────────────────────── mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' }); @@ -68,6 +63,5 @@ module.exports = { mdl_Payments, mdl_PlanCourses, mdl_PlanPolicies, - mdl_PlanPrices, mdl_SystemBadges, }; diff --git a/models/users/achievement_definitions.mdl.js b/models/users/achievement_definitions.mdl.js new file mode 100644 index 0000000..bbd251d --- /dev/null +++ b/models/users/achievement_definitions.mdl.js @@ -0,0 +1,32 @@ +/*********************************************************************************************************************************************************************** + * File Name: achievement_definitions.mdl.js + * Type of Program: Model + * Description: Admin-managed catalog of achievements (badges and milestones). + * Replaces the old static ACHIEVEMENT_REGISTRY in data/achievements.data.js. + * `is_system` rows are the built-in keys referenced by name in + * services/achievements.service.js's trigger functions — protected + * from deletion/key-rename by the admin controller. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 3, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const mdl_AchievementDefinitions = sequelize.define('AchievementDefinition', { + achievement_definition_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 }, + key: { type: DataTypes.STRING(100), allowNull: false, unique: true, label: 'Key', hidden: false, order: 1, filterable: true }, + type: { type: DataTypes.ENUM('badge', 'milestone'), allowNull: false, defaultValue: 'badge', label: 'Type', hidden: false, order: 2, filterable: true }, + label: { type: DataTypes.STRING(255), allowNull: false, label: 'Label', hidden: false, order: 3, filterable: true }, + description: { type: DataTypes.TEXT, allowNull: true, label: 'Description', hidden: false, order: 4, filterable: false }, + icon: { type: DataTypes.STRING(50), allowNull: true, label: 'Icon', hidden: false, order: 5, filterable: false }, + trigger: { type: DataTypes.STRING(30), allowNull: true, label: 'Trigger', hidden: false, order: 6, filterable: true }, + is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active', hidden: false, order: 7, filterable: true }, + is_system: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'System', hidden: false, order: 8, filterable: true }, +}, { + tableName: 'achievement_definitions', + timestamps: true, + paranoid: false, +}); + +module.exports = mdl_AchievementDefinitions; diff --git a/models/users/achievements.mdl.js b/models/users/achievements.mdl.js index 01678ba..94fb1c9 100644 --- a/models/users/achievements.mdl.js +++ b/models/users/achievements.mdl.js @@ -26,6 +26,7 @@ const mdl_Achievements = sequelize.define('Achievement', { key: { type: DataTypes.STRING(100), allowNull: false, label: 'Key', hidden: false, order: 2, filterable: true }, label: { type: DataTypes.STRING(255), allowNull: false, label: 'Label', hidden: false, order: 3, filterable: true }, description: { type: DataTypes.TEXT, allowNull: true, label: 'Description', hidden: false, order: 4, filterable: false }, + icon: { type: DataTypes.STRING(50), allowNull: true, label: 'Icon', hidden: false, order: 5, filterable: false }, granted_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Granted By', hidden: false, order: 5, filterable: false }, granted_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, label: 'Granted At', hidden: false, order: 6, filterable: false }, metadata: { type: DataTypes.JSONB, allowNull: true, defaultValue: {}, label: 'Metadata', hidden: true, order: 0 }, diff --git a/models/users/users.mdl.js b/models/users/users.mdl.js index 0615b48..b048d8c 100644 --- a/models/users/users.mdl.js +++ b/models/users/users.mdl.js @@ -43,9 +43,6 @@ const mdl_Users = sequelize.define('User', { */ personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 }, - // ── Currency preference ────────────────────────────────────────────────────── - preferred_currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Preferred Currency' }, - // ── Ban state ──────────────────────────────────────────────────────────────── is_banned: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Banned" }, ban_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Ban Expires At" }, diff --git a/package-lock.json b/package-lock.json index fbe6860..85ee9a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "cookie-parser": "^1.4.6", "cors": "^2.8.5", "csurf": "^1.11.0", + "currency-codes": "^2.2.0", "dotenv": "^16.0.3", "express": "^4.18.2", "express-rate-limit": "^6.10.0", @@ -3358,6 +3359,16 @@ "node": ">=0.6" } }, + "node_modules/currency-codes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/currency-codes/-/currency-codes-2.2.0.tgz", + "integrity": "sha512-vpbQc5sEYHGdTVAYUhHnKv0DWiYLRvzl/KKyqeHzBh7HD/j3UlWoScpZ9tN/jG6w2feddWoObsBbaNVu5yDapg==", + "license": "MIT", + "dependencies": { + "first-match": "~0.0.1", + "nub": "~0.0.0" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -3966,6 +3977,12 @@ "node": ">=8" } }, + "node_modules/first-match": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/first-match/-/first-match-0.0.1.tgz", + "integrity": "sha512-VvKbnaxrC0polTFDC+teKPTdl2mn6B/KUW+WB3C9RzKDeNwbzfLdnUz3FxC+tnjvus6bI0jWrWicQyVIPdS37A==", + "license": "MIT" + }, "node_modules/fluent-ffmpeg": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", @@ -6241,6 +6258,15 @@ "node": ">=8" } }, + "node_modules/nub": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/nub/-/nub-0.0.0.tgz", + "integrity": "sha512-dK0Ss9C34R/vV0FfYJXuqDAqHlaW9fvWVufq9MmGF2umCuDbd5GRfRD9fpi/LiM0l4ZXf8IBB+RYmZExqCrf0w==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index c85c5a0..4874b71 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "cookie-parser": "^1.4.6", "cors": "^2.8.5", "csurf": "^1.11.0", + "currency-codes": "^2.2.0", "dotenv": "^16.0.3", "express": "^4.18.2", "express-rate-limit": "^6.10.0", @@ -52,7 +53,9 @@ "sequelize-cli": "^6.6.5" }, "jest": { - "testMatch": ["**/tests/**/*.test.js"], + "testMatch": [ + "**/tests/**/*.test.js" + ], "forceExit": true } } diff --git a/routes/admin/achievements.routes.js b/routes/admin/achievements.routes.js new file mode 100644 index 0000000..3235aa3 --- /dev/null +++ b/routes/admin/achievements.routes.js @@ -0,0 +1,14 @@ +'use strict'; + +const router = require('express').Router(); +const ctrl = require('../../controllers/admin/achievements.controller'); + +// Auth + requireAdmin applied by admin.routes.js + +router.get ('/', ctrl.getAchievements); +router.post ('/', ctrl.createAchievement); +router.get ('/:id', ctrl.getAchievement); +router.put ('/:id', ctrl.updateAchievement); +router.delete('/:id', ctrl.deleteAchievement); + +module.exports = router; diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index 49c42d9..059922b 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -37,7 +37,12 @@ const categoriesRoutes = require('./categories.routes'); const productsRoutes = require('./products.routes'); const advertisementRoutes = require('./advertisements.routes'); const notificationRoutes = require('./notifications.routes'); +const notificationBroadcastRoutes = require('./notificationBroadcasts.routes'); +const notificationSettingsRoutes = require('./notificationSettings.routes'); const mediaRoutes = require('./media.routes'); +const achievementsRoutes = require('./achievements.routes'); +const emailTemplatesRoutes = require('./email_templates.routes'); +const emailBroadcastsRoutes = require('./email_broadcasts.routes'); const activityCtrl = require('../../controllers/admin/user_activity.controller'); // ── Guards — applied to ALL admin routes ────────────────────────────────────── @@ -58,7 +63,12 @@ router.use('/categories', categoriesRoutes); router.use('/products', productsRoutes); router.use('/advertisements', advertisementRoutes); router.use('/notifications', notificationRoutes); +router.use('/notification-broadcasts', notificationBroadcastRoutes); +router.use('/notification-settings', notificationSettingsRoutes); router.use('/media', mediaRoutes); +router.use('/achievements', achievementsRoutes); +router.use('/email-templates', emailTemplatesRoutes); +router.use('/email-broadcasts', emailBroadcastsRoutes); // ── Activity feed — global ──────────────────────────────────────────────────── router.get('/activity', activityCtrl.getActivity); diff --git a/routes/admin/courses.routes.js b/routes/admin/courses.routes.js index aef89a3..8a29a68 100644 --- a/routes/admin/courses.routes.js +++ b/routes/admin/courses.routes.js @@ -28,6 +28,7 @@ router.get("/lessons-flat", ctrl.getLessonsFlat); // ── then :courseId ──────────────────────────────────────────────────────────── router.get("/archives/:courseId", ctrl.getArchivedCourse); router.patch("/:courseId/restore", ctrl.restoreCourse); +router.get("/:courseId/archive-impact", ctrl.getCourseArchiveImpact); router.get("/:courseId", ctrl.getCourse); router.put("/:courseId", ctrl.updateCourse); router.delete("/:courseId", ctrl.archiveCourse); @@ -75,6 +76,7 @@ router.post("/:courseId/assessment/:assessmentId/questions", ctrl.createQuestion // static before :questionId router.delete("/:courseId/assessment/:assessmentId/questions/bulk", ctrl.bulkArchiveQuestions); router.patch("/:courseId/assessment/:assessmentId/questions/restore/bulk", ctrl.bulkRestoreQuestions); +router.put("/:courseId/assessment/:assessmentId/questions/bulk-sync", ctrl.bulkSyncQuestions); router.get("/:courseId/assessment/:assessmentId/questions/archives/:questionId", ctrl.getArchivedQuestion); router.patch("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.updateQuestion); @@ -100,6 +102,7 @@ router.patch("/:courseId/units/restore/bulk", ctrl.bulkRestoreUnits); router.get("/:courseId/units/archives/:unitId", ctrl.getArchivedUnit); router.patch("/:courseId/units/:unitId/restore", ctrl.restoreUnit); +router.get("/:courseId/units/:unitId/archive-impact", ctrl.getUnitArchiveImpact); router.get("/:courseId/units/:unitId", ctrl.getUnit); router.put("/:courseId/units/:unitId", ctrl.updateUnit); router.delete("/:courseId/units/:unitId", ctrl.archiveUnit); @@ -125,6 +128,7 @@ router.post("/:courseId/units/:unitId/quiz/:quizId/questions", ctrl.createQuesti // static before :questionId router.delete("/:courseId/units/:unitId/quiz/:quizId/questions/bulk", ctrl.bulkArchiveQuestions); router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/restore/bulk", ctrl.bulkRestoreQuestions); +router.put("/:courseId/units/:unitId/quiz/:quizId/questions/bulk-sync", ctrl.bulkSyncQuestions); router.get("/:courseId/units/:unitId/quiz/:quizId/questions/archives/:questionId", ctrl.getArchivedQuestion); router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId", ctrl.updateQuestion); diff --git a/routes/admin/email_broadcasts.routes.js b/routes/admin/email_broadcasts.routes.js new file mode 100644 index 0000000..cc1b380 --- /dev/null +++ b/routes/admin/email_broadcasts.routes.js @@ -0,0 +1,13 @@ +'use strict'; + +const router = require('express').Router(); +const ctrl = require('../../controllers/admin/email_broadcasts.controller'); + +// Auth + requireAdmin applied by admin.routes.js + +router.get ('/', ctrl.getEmailBroadcasts); +router.post ('/', ctrl.createEmailBroadcast); +router.get ('/:id', ctrl.getEmailBroadcast); +router.patch ('/:id/cancel', ctrl.cancelEmailBroadcast); + +module.exports = router; diff --git a/routes/admin/email_templates.routes.js b/routes/admin/email_templates.routes.js new file mode 100644 index 0000000..4d05d83 --- /dev/null +++ b/routes/admin/email_templates.routes.js @@ -0,0 +1,14 @@ +'use strict'; + +const router = require('express').Router(); +const ctrl = require('../../controllers/admin/email_templates.controller'); + +// Auth + requireAdmin applied by admin.routes.js + +router.get ('/', ctrl.getEmailTemplates); +router.post ('/', ctrl.createEmailTemplate); +router.get ('/:id', ctrl.getEmailTemplate); +router.put ('/:id', ctrl.updateEmailTemplate); +router.delete('/:id', ctrl.deleteEmailTemplate); + +module.exports = router; diff --git a/routes/admin/notificationBroadcasts.routes.js b/routes/admin/notificationBroadcasts.routes.js new file mode 100644 index 0000000..915021c --- /dev/null +++ b/routes/admin/notificationBroadcasts.routes.js @@ -0,0 +1,22 @@ +// routes/admin/notificationBroadcasts.routes.js +const router = require('express').Router(); +const controller = require('../../controllers/admin/notificationBroadcasts.controller'); +const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware'); + +// ─── Static routes first ────────────────────────────────────────────────────── +router.get('/archived', controller.getArchivedBroadcasts); +router.delete('/bulk', sensitiveOpsLimiter, controller.archiveBroadcasts); +router.patch('/bulk-restore', controller.restoreBroadcasts); + +// ─── Collection ─────────────────────────────────────────────────────────────── +router.get('/', controller.getBroadcasts); +router.post('/', controller.createBroadcast); + +// ─── Dynamic routes last ────────────────────────────────────────────────────── +router.get('/:broadcastId', controller.getBroadcast); +router.patch('/:broadcastId', sensitiveOpsLimiter, controller.updateBroadcast); +router.patch('/:broadcastId/send', sensitiveOpsLimiter, controller.sendBroadcast); +router.patch('/:broadcastId/restore', sensitiveOpsLimiter, controller.restoreBroadcast); +router.delete('/:broadcastId', sensitiveOpsLimiter, controller.archiveBroadcast); + +module.exports = router; diff --git a/routes/admin/notificationSettings.routes.js b/routes/admin/notificationSettings.routes.js new file mode 100644 index 0000000..f44c3a8 --- /dev/null +++ b/routes/admin/notificationSettings.routes.js @@ -0,0 +1,8 @@ +// routes/admin/notificationSettings.routes.js +const router = require('express').Router(); +const controller = require('../../controllers/admin/notificationSettings.controller'); + +router.get('/', controller.getSettings); +router.patch('/:jobName', controller.updateSetting); + +module.exports = router; diff --git a/routes/admin/tiers.routes.js b/routes/admin/tiers.routes.js index a351e25..dfe09af 100644 --- a/routes/admin/tiers.routes.js +++ b/routes/admin/tiers.routes.js @@ -1,8 +1,8 @@ const express = require('express'); const router = express.Router(); const ctrl = require('../../controllers/admin/tiers.controller'); -const priceCtrl = require('../../controllers/admin/plan_prices.controller'); +router.get ('/currencies', ctrl.getCurrencies); router.get ('/', ctrl.getPlans); router.post ('/', ctrl.createPlan); router.get ('/field-values', ctrl.getPlanFieldValues); @@ -17,16 +17,11 @@ router.get ('/users/:id/tiers', ctrl.getUserTiers); router.post ('/users/tiers/grant', ctrl.grantTier); router.patch ('/users/tiers/:tid/revoke', ctrl.revokeTier); -// ← course + impact + prices routes before /:id +// ← course + impact routes before /:id router.get ('/:id/impact', ctrl.getPlanImpact); router.get ('/:id/courses', ctrl.getPlanCourses); router.post ('/:id/courses', ctrl.syncPlanCourses); -router.get ('/:id/prices', priceCtrl.getPrices); -router.post ('/:id/prices', priceCtrl.addPrice); -router.put ('/:id/prices/:currency', priceCtrl.updatePrice); -router.delete('/:id/prices/:currency', priceCtrl.removePrice); - router.get ('/:id', ctrl.getPlan); router.put ('/:id', ctrl.updatePlan); router.delete('/:id', ctrl.archivePlan); diff --git a/routes/client/advertisements.routes.js b/routes/client/advertisements.routes.js index 6f0f8ee..484bba0 100644 --- a/routes/client/advertisements.routes.js +++ b/routes/client/advertisements.routes.js @@ -2,9 +2,12 @@ const router = require('express').Router(); const controller = require('../../controllers/client/advertisements.controller'); -// ─── GET /api/client/advertisements/active?type=hero ────────────────────────── +// ─── GET /api/client/advertisements/active?placement=dashboard.hero ─────────── router.get('/active', controller.getActiveAdvertisement); +// ─── GET /api/client/advertisements/active-batch?placements=a,b,c ───────────── +router.get('/active-batch', controller.getActiveAdvertisements); + // ─── POST /api/client/advertisements/:advertisementId/click ─────────────────── router.post('/:advertisementId/click', controller.trackClick); diff --git a/routes/client/client.routes.js b/routes/client/client.routes.js index f1d71f6..a6864f2 100644 --- a/routes/client/client.routes.js +++ b/routes/client/client.routes.js @@ -50,7 +50,6 @@ router.use(authenticate, requireClient()); router.get('/profile', profileCtrl.getProfile); router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile); router.delete('/profile', profileCtrl.deleteAccount); -router.patch('/profile/currency', profileCtrl.updateCurrency); router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar); router.delete('/profile/avatar', profileCtrl.deleteAvatar); router.get('/sessions', profileCtrl.getSessions); diff --git a/routes/client/notifications.routes.js b/routes/client/notifications.routes.js index 62c2840..da50931 100644 --- a/routes/client/notifications.routes.js +++ b/routes/client/notifications.routes.js @@ -3,10 +3,11 @@ * Type : Router (Client) * Description : Per-user notification endpoints. * - * GET /api/client/notifications — paginated list - * GET /api/client/notifications/unseen — unseen count - * PATCH /api/client/notifications/seen-all — mark all seen - * PATCH /api/client/notifications/:id/seen — mark one seen + * GET /api/client/notifications — paginated list + * GET /api/client/notifications/unseen — unseen count + * PATCH /api/client/notifications/seen-all — mark all seen + * PATCH /api/client/notifications/:id/seen — mark one seen + * DELETE /api/client/notifications/clear-all — delete all notifications * * Guards: inherited from client.routes.js (authenticate → requireClient) * @@ -15,11 +16,12 @@ ***********************************************************************************************************************************************************************/ const express = require('express'); const router = express.Router(); -const { list, unseenCount, markSeen, markAllSeen } = require('../../controllers/client/notification.controller'); +const { list, unseenCount, markSeen, markAllSeen, clearAll } = require('../../controllers/client/notification.controller'); router.get('/', list); router.get('/unseen', unseenCount); router.patch('/seen-all', markAllSeen); router.patch('/:id/seen', markSeen); +router.delete('/clear-all', clearAll); module.exports = router; diff --git a/routes/client/tiers.routes.js b/routes/client/tiers.routes.js index 471e453..2552a14 100644 --- a/routes/client/tiers.routes.js +++ b/routes/client/tiers.routes.js @@ -1,7 +1,6 @@ const express = require('express'); const router = express.Router(); const ctrl = require('../../controllers/client/tiers.controller'); -const priceCtrl = require('../../controllers/admin/plan_prices.controller'); // My tier router.get ('/me', ctrl.getMyTier); @@ -28,7 +27,4 @@ router.get ('/categories', ctrl.getCategories); // System badges (public read for profile display) router.get ('/system-badges', ctrl.getSystemBadges); -// Supported currencies (public — used by currency picker in settings + checkout) -router.get ('/currencies', priceCtrl.getCurrencies); - module.exports = router; \ No newline at end of file diff --git a/server.js b/server.js index e1c2991..4304e70 100644 --- a/server.js +++ b/server.js @@ -44,6 +44,7 @@ require('./models/users/user_sessions.mdl'); require('./models/users/user_groups.mdl'); require('./models/notifications/admin_notification.mdl'); require('./models/notifications/user_notification.mdl'); +require('./models/notifications/notification_broadcast.mdl'); // ── Cron jobs ────────────────────────────────────────────────────────────────── const { startAdminCronJobs } = require('./cron/admin.cron'); @@ -169,8 +170,8 @@ function printCronTable(jobs) { console.log('✅ Database connected.'); const cronJobs = [ - ...startAdminCronJobs(), - ...startClientCronJobs(), + ...(await startAdminCronJobs()), + ...(await startClientCronJobs()), ]; app.listen(PORT, () => { diff --git a/services/achievements.service.js b/services/achievements.service.js index 3c39729..17cf22f 100644 --- a/services/achievements.service.js +++ b/services/achievements.service.js @@ -10,10 +10,11 @@ ***********************************************************************************************************************************************************************/ 'use strict'; -const { Op } = require('sequelize'); -const mdl_Achievements = require('../models/users/achievements.mdl'); -const mdl_Users = require('../models/users/users.mdl'); -const { EARLY_ACCESS_CUTOFF, ACHIEVEMENT_REGISTRY } = require('../data/achievements.data'); +const { Op } = require('sequelize'); +const mdl_Achievements = require('../models/users/achievements.mdl'); +const mdl_AchievementDefinitions = require('../models/users/achievement_definitions.mdl'); +const mdl_Users = require('../models/users/users.mdl'); +const { EARLY_ACCESS_CUTOFF } = require('../data/achievements.data'); const UserNotification = require('../models/notifications/user_notification.mdl'); const { NOTIFICATION_REGISTRY } = require('../data/notifications.data'); @@ -24,15 +25,15 @@ const { NOTIFICATION_REGISTRY } = require('../data/notifications.data'); * Safe to call multiple times — idempotent via findOrCreate. * * @param {string|number} user_id - * @param {string} key — must exist in ACHIEVEMENT_REGISTRY + * @param {string} key — must exist in the achievement_definitions catalog * @param {Object} metadata — optional extra data (course_id, score, etc.) * @param {string|number} granted_by — null = system, user_id = admin manual grant * @returns {{ achievement, created }} or null on error */ async function grantAchievement(user_id, key, metadata = {}, granted_by = null) { - const def = ACHIEVEMENT_REGISTRY[key]; + const def = await mdl_AchievementDefinitions.findOne({ where: { key, is_active: true } }); if (!def) { - console.warn(`[ACHIEVEMENTS] Unknown achievement key: "${key}"`); + console.warn(`[ACHIEVEMENTS] Unknown or inactive achievement key: "${key}"`); return null; } @@ -47,6 +48,7 @@ async function grantAchievement(user_id, key, metadata = {}, granted_by = null) key: def.key, label: def.label, description: def.description, + icon: def.icon, granted_by: granted_by ?? null, granted_at: new Date(), metadata, @@ -140,7 +142,6 @@ async function backfillEarlyAccess() { // ─── Exports ────────────────────────────────────────────────────────────────── module.exports = { - ACHIEVEMENT_REGISTRY, grantAchievement, // Convenience triggers diff --git a/services/certificate-record.service.js b/services/certificate-record.service.js new file mode 100644 index 0000000..72a2015 --- /dev/null +++ b/services/certificate-record.service.js @@ -0,0 +1,107 @@ +/*********************************************************************************************************************************************************************** + * File Name : certificate-record.service.js + * Type : Service + * Description : Resolves or lazily creates the persisted `certificates` row for a + * user/course pair (cert_no/ref_no assignment, instructor snapshot). + * Shared by the PDF download endpoint (certificate.controller.js) and + * the hourly issuance cron (cron/jobs/issue_certificates.cron.js) so + * both write through the same cert_no/ref_no sequence. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 2, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const { formatDuration } = require('../utils/duration.util'); + +const { + Course, + CourseAssessment, + QuizAttempt, + Certificate, + CourseInstructor, +} = require('../models/courses/courses.associations'); + +function formatInstructors(rows) { + const names = rows.map(r => r.display_name); + if (names.length === 0) return ''; + if (names.length === 1) return names[0]; + if (names.length === 2) return `${names[0]} and ${names[1]}`; + return `${names[0]}, ${names[1]} and et. al`; +} + +// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5} +async function buildCertNo(userId) { + const count = await Certificate.count({ where: { user_id: userId } }); + const seq = String(count + 1).padStart(5, '0'); + const uid = String(userId).padStart(6, '0'); + const now = new Date(); + const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`; + return `${YYYYMM}-${uid}-${seq}`; +} + +// ref_no format: PP-YYYYMM-{globalSeq:5} (unique across all certs) +async function buildRefNo() { + const count = await Certificate.count(); + const seq = String(count + 1).padStart(5, '0'); + const now = new Date(); + const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`; + return `PP-${YYYYMM}-${seq}`; +} + +/** + * Resolves (or creates) the Certificate row for a user/course. Idempotent — + * safe to call from both the cron job and the on-demand download endpoint. + * Returns null if the course has no assessment or the user hasn't passed it. + */ +async function ensureCertificateRecord({ userId, courseId }) { + const course = await Course.findOne({ + where: { course_id: courseId }, + attributes: ['course_id', 'title', 'duration_seconds'], + include: [ + { model: CourseAssessment, as: 'assessment', attributes: ['assessment_id'], required: false }, + { + model: CourseInstructor, + as: 'instructors', + attributes: ['display_name', 'order_index'], + required: false, + order: [['order_index', 'ASC']], + }, + ], + }); + if (!course || !course.assessment) return null; + + const passedAttempt = await QuizAttempt.findOne({ + where: { + user_id: userId, + assessment_id: course.assessment.assessment_id, + passed: true, + }, + order: [['createdAt', 'DESC']], + attributes: ['score', 'createdAt'], + }); + if (!passedAttempt) return null; + + const liveInstructors = formatInstructors(course.instructors ?? []); + + // CockroachDB does not support findOrCreate (uses temp PL/pgSQL functions). + let cert = await Certificate.findOne({ where: { user_id: userId, course_id: courseId } }); + if (!cert) { + cert = await Certificate.create({ + user_id: userId, + course_id: courseId, + cert_no: await buildCertNo(userId), + ref_no: await buildRefNo(), + instructors: liveInstructors, + score: passedAttempt.score ?? null, + length_str: formatDuration(course.duration_seconds), + issued_at: passedAttempt.createdAt, + }); + } else if (liveInstructors !== (cert.instructors ?? '')) { + await cert.update({ instructors: liveInstructors }); + } + + return cert; +} + +module.exports = { ensureCertificateRecord, formatInstructors, buildCertNo, buildRefNo }; diff --git a/services/email.service.js b/services/email.service.js index d95b010..37ddb2f 100644 --- a/services/email.service.js +++ b/services/email.service.js @@ -2,18 +2,22 @@ * File Name: email.service.js * Type of Program: Service * Description: Nodemailer-based email service. - * Provides: - * - sendOTPEmail() → sends a 6-digit OTP verification email - * - sendWelcomeEmail() → sent after successful email verification - * Author: rgrgogu + * Subject + body per type are loaded from the email_templates + * table (admin-editable, see controllers/admin/email_templates.controller.js). + * The outer layout (header/footer/signature) below is fixed in + * code and is NOT admin-editable — only the body content is. + * Author: rgrgogu, Kenneth Obsequio (@lash0000) * Date Created: Oct. 6, 2025 + * Date Modified: Jul. 3, 2026 — templates moved from data/email_body.data.js into the DB *********************************************************************************************************************************************************************** * HOW TO USE: * const emailService = require('../services/email.service'); - * await emailService.sendOTPEmail(user.email, otp); + * await emailService.sendEmail({ to: user.email, type: 'OTP', data: { otp } }); ***********************************************************************************************************************************************************************/ const nodemailer = require('nodemailer'); -const { emailTemplates } = require('../data/email_body.data') +const mdl_EmailTemplate = require('../models/email_templates/email_templates.mdl'); +const { enrichEmailData } = require('../data/email_template_enrichers.data'); +const { renderTemplate } = require('../utils/renderTemplate.util'); const port = Number(process.env.SMTP_PORT); @@ -31,15 +35,34 @@ const transporter = nodemailer.createTransport({ }, }); +// Fixed layout — admins cannot change header/footer/signature via the CRUD, +// only the body content per template type. +const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;'; +const wrap = (body) => ` + + + ${body.trim()} +

+

Regards,
Philproperties IT Team

+

This is an automated message from STARR System. Please do not reply.

+ +`.trim(); + const sendEmail = async ({ to, type, data = {} }) => { try { - const templateFn = emailTemplates[type]; - - if (!templateFn) { + const template = await mdl_EmailTemplate.findOne({ where: { type } }); + if (!template) { throw new Error(`Email template "${type}" not found`); } + // Draft content (or a template that's never been sent) never reaches + // real mail — only the live subject/html_body columns count as "published". + if (!template.subject || !template.html_body) { + throw new Error(`Email template "${type}" has no published (sent) version yet`); + } - const { subject, html } = templateFn(data); + const enriched = enrichEmailData(type, data); + const subject = renderTemplate(template.subject, enriched); + const html = wrap(renderTemplate(template.html_body, enriched)); return await new Promise((resolve, reject) => { transporter.sendMail( @@ -65,4 +88,4 @@ const sendEmail = async ({ to, type, data = {} }) => { const ping = () => transporter.verify(); -module.exports = { sendEmail, ping }; \ No newline at end of file +module.exports = { sendEmail, ping }; diff --git a/services/mediaToken.service.js b/services/mediaToken.service.js new file mode 100644 index 0000000..fe46a37 --- /dev/null +++ b/services/mediaToken.service.js @@ -0,0 +1,117 @@ +/*********************************************************************************************************************************************************************** + * File Name: mediaToken.service.js + * Type of Program: Service + * Description: Issues + caches short-lived JWT stream tokens (and presigned S3 + * thumbnail URLs) for asset preview. Shared by: + * - controllers/admin/media.controller.js (POST /admin/media/token(s)) + * - controllers/admin/assets.controller.js (embeds tokens directly + * into GET /admin/assets rows so pickers don't need a second + * round-trip just to render thumbnails) + * + * Author: Kenneth Obsequio (@lash0000) + ***********************************************************************************************************************************************************************/ +"use strict"; + +const jwt = require("jsonwebtoken"); +const s3 = require("./s3.service"); + +const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET; +const TOKEN_TTL_SEC = 30 * 60; // 30 min — admin preview session +const SUPPORTED_TYPES = ["video", "audio", "document", "image"]; + +// ─── In-memory token cache (no Redis yet) ────────────────────────────────────── +// Avoids re-signing a JWT / re-presigning the thumbnail S3 URL for an asset that +// already has a still-valid token. Keyed by (asset_id, ip) because the stream +// endpoint (/api/client/media/stream/:token) pins the token to the issuing +// request's IP — reusing a token minted for a different IP would get rejected. +// Single-process only; each app instance keeps its own cache. +const TOKEN_CACHE_MARGIN_SEC = 120; // re-mint a bit before actual expiry +const tokenCache = new Map(); // `${asset_id}:${ip}` -> { token, thumbnail_url, expiresAt } + +function tokenCacheKey(assetId, ip) { + return `${assetId}:${ip}`; +} + +function getCachedToken(assetId, ip) { + const key = tokenCacheKey(assetId, ip); + const entry = tokenCache.get(key); + if (!entry) return null; + if (Date.now() >= entry.expiresAt) { + tokenCache.delete(key); + return null; + } + return entry; +} + +function setCachedToken(assetId, ip, token, thumbnail_url) { + tokenCache.set(tokenCacheKey(assetId, ip), { + token, + thumbnail_url, + expiresAt: Date.now() + (TOKEN_TTL_SEC - TOKEN_CACHE_MARGIN_SEC) * 1000, + }); +} + +// Collapses IPv4-mapped IPv6 ("::ffff:127.0.0.1") and IPv6 loopback ("::1") +// down to a single canonical form. Without this, a token minted off one +// "localhost" connection (IPv4) fails IP-pin verification on a sibling +// request that happened to land on the other stack (IPv6) — browsers race +// both when resolving "localhost", so mint and stream requests can land on +// different stacks even from the same client. +function normalizeIp(ip) { + if (ip === "::1") return "127.0.0.1"; + if (ip.startsWith("::ffff:")) return ip.slice(7); + return ip; +} + +function resolveIp(req) { + const forwarded = req.headers["x-forwarded-for"]; + const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown"); + return normalizeIp(raw); +} + +function signToken(asset, userId, ip) { + return jwt.sign( + { + asset_id: asset.asset_id, + user_id: userId, + storage_key: asset.storage_key, + file_type: asset.file_type, + mime_type: asset.mime_type, + ip, + }, + MEDIA_SECRET, + { expiresIn: TOKEN_TTL_SEC } + ); +} + +// ─── issueForAsset ───────────────────────────────────────────────────────────── +// +// Returns { token, thumbnail_url } for an S3 asset, minting + caching on first +// call and serving from tokenCache on subsequent calls within the TTL margin. +// `asset` needs: asset_id, storage_key, file_type, mime_type, thumbnail_storage_key. +// +async function issueForAsset(asset, userId, ip) { + const cached = getCachedToken(asset.asset_id, ip); + if (cached) return { token: cached.token, thumbnail_url: cached.thumbnail_url }; + + const token = signToken(asset, userId, ip); + + let thumbnail_url = null; + if (asset.thumbnail_storage_key) { + try { + thumbnail_url = await s3.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC); + } catch { + // Non-fatal — thumbnail is cosmetic + } + } + + setCachedToken(asset.asset_id, ip, token, thumbnail_url); + return { token, thumbnail_url }; +} + +module.exports = { + TOKEN_TTL_SEC, + SUPPORTED_TYPES, + resolveIp, + issueForAsset, +}; diff --git a/services/paypal.service.js b/services/paypal.service.js deleted file mode 100644 index 043552e..0000000 --- a/services/paypal.service.js +++ /dev/null @@ -1,75 +0,0 @@ -/*********************************************************************************************************************************************************************** - * File Name: paypal.service.js - * Type of Program: Service - * Description: PayPal Orders API helpers — create order, capture order. - * Uses client-side JS SDK button → server capture flow. - * Author: Kenneth Obsequio (@lash0000) - * Date Created: Jun. 6, 2026 - ***********************************************************************************************************************************************************************/ -const axios = require('axios'); - -const BASE_URL = process.env.PAYPAL_ENV === 'live' - ? 'https://api-m.paypal.com' - : 'https://api-m.sandbox.paypal.com'; - -const getAccessToken = async () => { - const { data } = await axios.post( - `${BASE_URL}/v1/oauth2/token`, - 'grant_type=client_credentials', - { - auth: { - username: process.env.PAYPAL_CLIENT_ID, - password: process.env.PAYPAL_CLIENT_SECRET, - }, - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - } - ); - return data.access_token; -}; - -exports.createOrder = async ({ amount, currency = 'USD', referenceId, returnUrl, cancelUrl }) => { - const token = await getAccessToken(); - const { data } = await axios.post( - `${BASE_URL}/v2/checkout/orders`, - { - intent: 'CAPTURE', - purchase_units: [{ - reference_id: referenceId, - amount: { currency_code: currency, value: String(amount) }, - }], - application_context: { - return_url: returnUrl ?? `${process.env.FRONTEND_URL}/plans/checkout`, - cancel_url: cancelUrl ?? `${process.env.FRONTEND_URL}/plans/checkout?cancelled=true`, - brand_name: 'Philproperties', - user_action: 'PAY_NOW', - }, - }, - { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } } - ); - return data; -}; - -exports.captureOrder = async (orderId) => { - const token = await getAccessToken(); - const { data } = await axios.post( - `${BASE_URL}/v2/checkout/orders/${orderId}/capture`, - {}, - { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } } - ); - return data; // { id, status, purchase_units, payer } -}; - -exports.refundCapture = async (captureId, amount, currency = 'USD') => { - const token = await getAccessToken(); - const { data } = await axios.post( - `${BASE_URL}/v2/payments/captures/${captureId}/refund`, - { - amount: { - value: String(amount), - currency_code: currency, - }, - }, - { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } } - ); - return data; // { id, status, amount, ... } -}; diff --git a/services/task_reading_progress_sync.service.js b/services/task_reading_progress_sync.service.js new file mode 100644 index 0000000..07d836d --- /dev/null +++ b/services/task_reading_progress_sync.service.js @@ -0,0 +1,138 @@ +/*********************************************************************************************************************************************************************** + * File Name: task_reading_progress_sync.service.js + * Type of Program: Service + * Description: Backfills task_progress for read_* task requirements from course_reading_progress. + * + * This covers the case where a user already completed reading a course/unit/lesson + * before a task requiring that item was created or assigned. + ***********************************************************************************************************************************************************************/ +'use strict'; + +const { Op } = require('sequelize'); +const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl'); +const { TaskProgress } = require('../models/task/task_progress.mdl'); + +const READ_TYPE_TO_PROGRESS_TYPE = { + read_course: 'course', + read_unit: 'unit', + read_lesson: 'lesson', +}; + +const READ_REQUIREMENT_TYPES = Object.keys(READ_TYPE_TO_PROGRESS_TYPE); + +function readAttr(row, attr) { + if (!row) return undefined; + if (typeof row.get === 'function') return row.get(attr); + return row[attr]; +} + +function normalizeRequirement(row) { + const type = readAttr(row, 'type'); + if (!READ_REQUIREMENT_TYPES.includes(type)) return null; + + const referenceId = readAttr(row, 'reference_id'); + if (!referenceId) return null; + + return { + task_id: readAttr(row, 'task_id'), + requirement_id: readAttr(row, 'requirement_id'), + reference_id: referenceId, + type, + }; +} + +async function hydrateReadTaskProgress(userId, requirements = [], options = {}) { + const readRequirements = requirements + .map(normalizeRequirement) + .filter((req) => req && req.task_id && req.requirement_id); + + if (!readRequirements.length) return []; + + const referencesByProgressType = readRequirements.reduce((acc, req) => { + const progressType = READ_TYPE_TO_PROGRESS_TYPE[req.type]; + if (!acc[progressType]) acc[progressType] = new Set(); + acc[progressType].add(req.reference_id); + return acc; + }, {}); + + const where = { + user_id: userId, + status: 'completed', + [Op.or]: Object.entries(referencesByProgressType).map(([type, references]) => ({ + type, + reference_id: { [Op.in]: [...references] }, + })), + }; + + const completedReadingRows = await CourseReadingProgress.findAll({ + where, + attributes: ['type', 'reference_id', 'completed_at'], + transaction: options.transaction, + }); + + if (!completedReadingRows.length) return []; + + const completedReading = new Map( + completedReadingRows.map((row) => [ + `${readAttr(row, 'type')}:${readAttr(row, 'reference_id')}`, + readAttr(row, 'completed_at'), + ]) + ); + + const now = new Date(); + const rowsToUpsert = readRequirements.filter((req) => + completedReading.has(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`) + ); + + if (!rowsToUpsert.length) return []; + + const existingProgressRows = await TaskProgress.findAll({ + where: { + user_id: userId, + completed: true, + requirement_id: { [Op.in]: rowsToUpsert.map((req) => req.requirement_id) }, + reference_id: { [Op.in]: rowsToUpsert.map((req) => req.reference_id) }, + }, + attributes: ['requirement_id', 'reference_id'], + transaction: options.transaction, + }); + + const existingProgress = new Set( + existingProgressRows.map((row) => + `${readAttr(row, 'requirement_id')}:${readAttr(row, 'reference_id')}` + ) + ); + + const missingRows = rowsToUpsert.filter((req) => + !existingProgress.has(`${req.requirement_id}:${req.reference_id}`) + ); + + if (!missingRows.length) return []; + + await Promise.all(missingRows.map((req) => + TaskProgress.upsert( + { + task_id: req.task_id, + requirement_id: req.requirement_id, + user_id: userId, + reference_id: req.reference_id, + type: req.type, + completed: true, + completed_at: completedReading.get(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`) ?? now, + createdBy: userId, + updatedBy: userId, + }, + { + conflictFields: ['requirement_id', 'user_id', 'reference_id'], + transaction: options.transaction, + } + ) + )); + + return missingRows; +} + +module.exports = { + hydrateReadTaskProgress, + READ_REQUIREMENT_TYPES, +}; diff --git a/tests/providers/paypal.provider.test.js b/tests/providers/paypal.provider.test.js new file mode 100644 index 0000000..300426f --- /dev/null +++ b/tests/providers/paypal.provider.test.js @@ -0,0 +1,201 @@ +'use strict'; + +// ── Integration test: paypal.provider.js against a mocked HTTP boundary ─────── +// Only axios (the true external boundary) is mocked — everything else in the +// provider (token exchange, URL selection, request shaping) runs for real. +// This is what tells us the provider is ready to go live: wrong env vars, a +// broken sandbox/live switch, or a malformed request body will fail here +// exactly like it would against the real PayPal API. + +jest.mock('axios'); + +const BASE_ENV = { + PAYPAL_CLIENT_ID: 'test-client-id', + PAYPAL_CLIENT_SECRET: 'test-client-secret', + PAYPAL_BRAND_NAME: 'STARR', + FRONTEND_URL: 'https://app.new-starr.test', +}; + +// BASE_URL is computed once at module load time from PAYPAL_ENV, so every +// test that cares about sandbox/live must reset the module registry first. +// axios must be re-required from the same fresh registry, otherwise its mock +// calls land on a different automock instance than the one the provider uses. +function loadProvider(envOverrides = {}) { + jest.resetModules(); + Object.assign(process.env, BASE_ENV, envOverrides); + const axios = require('axios'); + const provider = require('../../providers/paypal.provider'); + return { provider, axios }; +} + +function mockAccessToken(axios, token = 'test-access-token') { + axios.post.mockImplementationOnce((url) => { + expect(url).toMatch(/\/v1\/oauth2\/token$/); + return Promise.resolve({ data: { access_token: token } }); + }); +} + +beforeEach(() => { + delete process.env.PAYPAL_ENV; +}); + +// ── Config: sandbox vs live host selection ──────────────────────────────────── + +describe('environment / config wiring', () => { + + test('defaults to the sandbox host when PAYPAL_ENV is unset', async () => { + const { provider, axios } = loadProvider({}); + mockAccessToken(axios); + axios.post.mockResolvedValueOnce({ data: {} }); + + await provider.createOrder({ amount: 10, referenceId: 'ref-1' }); + + expect(axios.post.mock.calls[0][0]).toBe('https://api-m.sandbox.paypal.com/v1/oauth2/token'); + expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders'); + }); + + test('switches to the live host when PAYPAL_ENV=live', async () => { + const { provider, axios } = loadProvider({ PAYPAL_ENV: 'live' }); + mockAccessToken(axios); + axios.post.mockResolvedValueOnce({ data: {} }); + + await provider.createOrder({ amount: 10, referenceId: 'ref-1' }); + + expect(axios.post.mock.calls[0][0]).toBe('https://api-m.paypal.com/v1/oauth2/token'); + expect(axios.post.mock.calls[1][0]).toBe('https://api-m.paypal.com/v2/checkout/orders'); + }); + + test('token request authenticates with PAYPAL_CLIENT_ID / PAYPAL_CLIENT_SECRET via HTTP Basic auth', async () => { + const { provider, axios } = loadProvider({}); + mockAccessToken(axios); + axios.post.mockResolvedValueOnce({ data: {} }); + + await provider.createOrder({ amount: 10, referenceId: 'ref-1' }); + + const [, , config] = axios.post.mock.calls[0]; + expect(config.auth).toEqual({ username: 'test-client-id', password: 'test-client-secret' }); + expect(config.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + }); + + test('a rejected token exchange (bad credentials) surfaces as a rejected promise, not a silent failure', async () => { + const { provider, axios } = loadProvider({}); + axios.post.mockRejectedValueOnce(new Error('401 invalid_client')); + + await expect(provider.createOrder({ amount: 10, referenceId: 'ref-1' })) + .rejects.toThrow('401 invalid_client'); + }); +}); + +// ── createOrder ──────────────────────────────────────────────────────────────── + +describe('createOrder()', () => { + + test('sends a CAPTURE intent order with the reference id, amount and currency', async () => { + const { provider, axios } = loadProvider({}); + mockAccessToken(axios, 'tok-abc'); + axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'CREATED' } }); + + const result = await provider.createOrder({ + amount: 19.99, + currency: 'PHP', + referenceId: 'plan-42', + }); + + const [url, body, config] = axios.post.mock.calls[1]; + expect(url).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders'); + expect(body.intent).toBe('CAPTURE'); + expect(body.purchase_units[0]).toMatchObject({ + reference_id: 'plan-42', + amount: { currency_code: 'PHP', value: '19.99' }, + }); + expect(config.headers.Authorization).toBe('Bearer tok-abc'); + expect(result).toEqual({ id: 'ORDER-1', status: 'CREATED' }); + }); + + test('defaults currency to USD when not provided', async () => { + const { provider, axios } = loadProvider({}); + mockAccessToken(axios); + axios.post.mockResolvedValueOnce({ data: {} }); + + await provider.createOrder({ amount: 5, referenceId: 'ref-1' }); + + const [, body] = axios.post.mock.calls[1]; + expect(body.purchase_units[0].amount.currency_code).toBe('USD'); + }); + + test('falls back to FRONTEND_URL for return/cancel urls when not provided', async () => { + const { provider, axios } = loadProvider({}); + mockAccessToken(axios); + axios.post.mockResolvedValueOnce({ data: {} }); + + await provider.createOrder({ amount: 5, referenceId: 'ref-1' }); + + const [, body] = axios.post.mock.calls[1]; + expect(body.application_context.return_url).toBe('https://app.new-starr.test/plans/checkout'); + expect(body.application_context.cancel_url).toBe('https://app.new-starr.test/plans/checkout?cancelled=true'); + }); + + test('honors explicit return/cancel urls when provided', async () => { + const { provider, axios } = loadProvider({}); + mockAccessToken(axios); + axios.post.mockResolvedValueOnce({ data: {} }); + + await provider.createOrder({ + amount: 5, referenceId: 'ref-1', + returnUrl: 'https://custom.test/ok', + cancelUrl: 'https://custom.test/cancel', + }); + + const [, body] = axios.post.mock.calls[1]; + expect(body.application_context.return_url).toBe('https://custom.test/ok'); + expect(body.application_context.cancel_url).toBe('https://custom.test/cancel'); + }); +}); + +// ── captureOrder ─────────────────────────────────────────────────────────────── + +describe('captureOrder()', () => { + + test('posts to the order capture endpoint with a bearer token and returns the capture payload', async () => { + const { provider, axios } = loadProvider({}); + mockAccessToken(axios, 'tok-xyz'); + axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'COMPLETED' } }); + + const result = await provider.captureOrder('ORDER-1'); + + const [url, body, config] = axios.post.mock.calls[1]; + expect(url).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders/ORDER-1/capture'); + expect(body).toEqual({}); + expect(config.headers.Authorization).toBe('Bearer tok-xyz'); + expect(result).toEqual({ id: 'ORDER-1', status: 'COMPLETED' }); + }); +}); + +// ── refundCapture ────────────────────────────────────────────────────────────── + +describe('refundCapture()', () => { + + test('posts a refund with the given amount and currency', async () => { + const { provider, axios } = loadProvider({}); + mockAccessToken(axios); + axios.post.mockResolvedValueOnce({ data: { id: 'REFUND-1', status: 'COMPLETED' } }); + + const result = await provider.refundCapture('CAPTURE-1', 9.5, 'PHP'); + + const [url, body] = axios.post.mock.calls[1]; + expect(url).toBe('https://api-m.sandbox.paypal.com/v2/payments/captures/CAPTURE-1/refund'); + expect(body).toEqual({ amount: { value: '9.5', currency_code: 'PHP' } }); + expect(result).toEqual({ id: 'REFUND-1', status: 'COMPLETED' }); + }); + + test('defaults currency to USD when not provided', async () => { + const { provider, axios } = loadProvider({}); + mockAccessToken(axios); + axios.post.mockResolvedValueOnce({ data: {} }); + + await provider.refundCapture('CAPTURE-1', 9.5); + + const [, body] = axios.post.mock.calls[1]; + expect(body.amount.currency_code).toBe('USD'); + }); +}); diff --git a/tests/providers/registry.test.js b/tests/providers/registry.test.js new file mode 100644 index 0000000..6484958 --- /dev/null +++ b/tests/providers/registry.test.js @@ -0,0 +1,51 @@ +'use strict'; + +// ── Provider contract test ──────────────────────────────────────────────────── +// This suite is the "gate" for onboarding new payment providers. It does not +// hardcode "paypal" as the only case — it walks registry.list() and asserts +// every registered provider satisfies the shape payment.service.js relies on. +// Drop a second provider into providers/registry.js and this file validates it +// for free, with zero new test code required. + +const registry = require('../../providers/registry'); + +const REQUIRED_METHODS = ['createOrder', 'captureOrder', 'refundCapture']; + +describe('payment provider registry', () => { + + test('lists at least one provider', () => { + expect(registry.list().length).toBeGreaterThan(0); + }); + + test('currently registers paypal', () => { + expect(registry.list()).toContain('paypal'); + }); + + test('get() returns the provider module for a known name', () => { + const provider = registry.get('paypal'); + expect(provider).toBeDefined(); + }); + + test('get() throws a descriptive error for an unknown provider', () => { + expect(() => registry.get('stripe')).toThrow(/Unknown payment provider: "stripe"/); + }); + + test('unknown-provider error lists the available providers so misconfiguration is easy to diagnose', () => { + try { + registry.get('does-not-exist'); + throw new Error('expected registry.get to throw'); + } catch (err) { + registry.list().forEach((name) => { + expect(err.message).toContain(name); + }); + } + }); + + describe.each(registry.list())('provider contract: %s', (name) => { + const provider = registry.get(name); + + test.each(REQUIRED_METHODS)('exposes %s as a function', (method) => { + expect(typeof provider[method]).toBe('function'); + }); + }); +}); diff --git a/tests/services/payment.service.test.js b/tests/services/payment.service.test.js new file mode 100644 index 0000000..4b6fff1 --- /dev/null +++ b/tests/services/payment.service.test.js @@ -0,0 +1,159 @@ +'use strict'; + +// ── Integration test: payment.service.js → providers/registry → paypal.provider ── +// Only the DB models and axios (the two real external boundaries) are mocked. +// registry.js and paypal.provider.js run unmodified, so this proves the full +// orchestration chain (service → registry lookup → provider → HTTP) is wired +// correctly end to end, not just that each piece works in isolation. + +process.env.PAYPAL_CLIENT_ID = 'test-client-id'; +process.env.PAYPAL_CLIENT_SECRET = 'test-client-secret'; +process.env.FRONTEND_URL = 'https://app.new-starr.test'; + +const axios = require('axios'); +jest.mock('axios'); + +jest.mock('../../models/tiers/payment_policies.mdl', () => ({ findOne: jest.fn() })); +jest.mock('../../models/tiers/payments.mdl', () => ({ count: jest.fn() })); + +const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl'); +const mdl_Payments = require('../../models/tiers/payments.mdl'); +const paymentService = require('../../services/payment.service'); + +beforeEach(() => jest.clearAllMocks()); + +function mockAccessToken(token = 'tok-abc') { + axios.post.mockImplementationOnce((url) => { + expect(url).toMatch(/\/v1\/oauth2\/token$/); + return Promise.resolve({ data: { access_token: token } }); + }); +} + +// ── Provider delegation ───────────────────────────────────────────────────────── + +describe('provider delegation (registry → paypal.provider → axios)', () => { + + test('createOrder("paypal", ...) reaches PayPal\'s create-order endpoint', async () => { + mockAccessToken(); + axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'CREATED' } }); + + const result = await paymentService.createOrder('paypal', { amount: 25, referenceId: 'plan-1' }); + + expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders'); + expect(result).toEqual({ id: 'ORDER-1', status: 'CREATED' }); + }); + + test('captureOrder("paypal", ...) reaches PayPal\'s capture endpoint', async () => { + mockAccessToken(); + axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'COMPLETED' } }); + + const result = await paymentService.captureOrder('paypal', 'ORDER-1'); + + expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders/ORDER-1/capture'); + expect(result).toEqual({ id: 'ORDER-1', status: 'COMPLETED' }); + }); + + test('refundCapture("paypal", ...) reaches PayPal\'s refund endpoint', async () => { + mockAccessToken(); + axios.post.mockResolvedValueOnce({ data: { id: 'REFUND-1', status: 'COMPLETED' } }); + + const result = await paymentService.refundCapture('paypal', 'CAPTURE-1', 10, 'USD'); + + expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/payments/captures/CAPTURE-1/refund'); + expect(result).toEqual({ id: 'REFUND-1', status: 'COMPLETED' }); + }); + + test('an unconfigured/unknown provider name fails fast with a descriptive error instead of hitting axios', () => { + expect(() => paymentService.createOrder('stripe', { amount: 25, referenceId: 'plan-1' })) + .toThrow(/Unknown payment provider: "stripe"/); + expect(axios.post).not.toHaveBeenCalled(); + }); +}); + +// ── Refund policy ──────────────────────────────────────────────────────────────── + +describe('refund policy', () => { + + test('falls back to the 5-minute default window when no policy is configured', () => { + expect(paymentService.getRefundWindowMs(null)).toBe(5 * 60_000); + expect(paymentService.isRefundAllowed(null)).toBe(true); + }); + + test('honors a custom policy window and unit', () => { + const policy = { refund_policy: { allowed: false, window_value: 2, window_unit: 'hours' } }; + expect(paymentService.getRefundWindowMs(policy)).toBe(2 * 3_600_000); + expect(paymentService.isRefundAllowed(policy)).toBe(false); + }); + + test('getPolicyForPlan() looks up the policy by plan_id', async () => { + mdl_PaymentPolicies.findOne.mockResolvedValue({ plan_id: 7 }); + const policy = await paymentService.getPolicyForPlan(7); + expect(mdl_PaymentPolicies.findOne).toHaveBeenCalledWith({ where: { plan_id: 7 } }); + expect(policy).toEqual({ plan_id: 7 }); + }); +}); + +// ── Promo evaluation ─────────────────────────────────────────────────────────── + +describe('evaluatePromo()', () => { + const plan = { plan_id: 1, price: 100 }; + + test('rejects when no code is provided', async () => { + const result = await paymentService.evaluatePromo({}, plan, ''); + expect(result).toEqual({ valid: false, reason: 'No promo code provided.' }); + }); + + test('rejects an unknown code', async () => { + const policy = { promo_rules: [] }; + const result = await paymentService.evaluatePromo(policy, plan, 'BOGUS'); + expect(result.valid).toBe(false); + expect(result.reason).toBe('Invalid promo code.'); + }); + + test('applies a flat discount', async () => { + const policy = { promo_rules: [{ code: 'FLAT10', type: 'flat', value: 10 }] }; + mdl_Payments.count.mockResolvedValue(0); + + const result = await paymentService.evaluatePromo(policy, plan, 'flat10'); + expect(result).toMatchObject({ valid: true, code: 'FLAT10', discount: 10 }); + }); + + test('applies a percent discount capped by max_discount', async () => { + const policy = { promo_rules: [{ code: 'PCT50', type: 'percent', value: 50, max_discount: 30 }] }; + mdl_Payments.count.mockResolvedValue(0); + + const result = await paymentService.evaluatePromo(policy, plan, 'PCT50'); + expect(result).toMatchObject({ valid: true, discount: 30 }); + }); + + test('rejects an expired code', async () => { + const policy = { promo_rules: [{ code: 'OLD', type: 'flat', value: 5, expires_at: '2000-01-01' }] }; + const result = await paymentService.evaluatePromo(policy, plan, 'OLD'); + expect(result).toMatchObject({ valid: false, reason: 'Promo code has expired.' }); + }); + + test('rejects once max_uses has been reached', async () => { + const policy = { promo_rules: [{ code: 'LIMITED', type: 'flat', value: 5, max_uses: 2 }] }; + mdl_Payments.count.mockResolvedValue(2); + + const result = await paymentService.evaluatePromo(policy, plan, 'LIMITED'); + expect(result).toMatchObject({ valid: false, reason: 'Promo code has reached its usage limit.' }); + }); + + test('rejects when the subtotal is below min_amount', async () => { + const policy = { promo_rules: [{ code: 'BIGSPEND', type: 'flat', value: 5, min_amount: 200 }] }; + mdl_Payments.count.mockResolvedValue(0); + + const result = await paymentService.evaluatePromo(policy, plan, 'BIGSPEND'); + expect(result.valid).toBe(false); + expect(result.reason).toMatch(/minimum purchase/); + }); + + test('evaluates against effectivePrice (localized currency) instead of plan.price when provided', async () => { + const policy = { promo_rules: [{ code: 'PCT10', type: 'percent', value: 10 }] }; + mdl_Payments.count.mockResolvedValue(0); + + const result = await paymentService.evaluatePromo(policy, plan, 'PCT10', 50); + expect(result.discount).toBe(5); + }); +}); diff --git a/utils/audienceResolver.util.js b/utils/audienceResolver.util.js new file mode 100644 index 0000000..5acba3c --- /dev/null +++ b/utils/audienceResolver.util.js @@ -0,0 +1,153 @@ +/*********************************************************************************************************************************************************************** + * File Name: audienceResolver.util.js + * Type of Program: Utility + * Description: Shared "who does this target reach" resolution for anything + * broadcast-shaped (notification broadcasts, email broadcasts). + * Extracted out of controllers/admin/notificationBroadcasts.controller.js + * so both features resolve task_list/course/tier_plan targeting + * identically instead of drifting apart. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 3, 2026 + ***********************************************************************************************************************************************************************/ +const { Op, QueryTypes } = require('sequelize'); +const sequelize = require('../config/db.config'); +const mdl_Users = require('../models/users/users.mdl'); +const { TaskList } = require('../models/task/task.mdl'); +const { Course } = require('../models/courses/courses.mdl'); +const mdl_TierCategories = require('../models/tiers/tier_categories.mdl'); +const mdl_UserTiers = require('../models/tiers/user_tiers.mdl'); +const mdl_TierPlans = require('../models/tiers/tier_plans.mdl'); +const mdl_Product = require('../models/courses/products.mdl'); +const mdl_CoursePurchase = require('../models/courses/course_purchases.mdl'); + +const ALLOWED_TARGET_TYPES = ["admin", "user", "both", "task_list", "course", "tier_plan"]; +const SCOPED_TARGET_TYPES = ["task_list", "course", "tier_plan"]; + +async function validateTargetId(target_type, target_id) { + if (target_type === "task_list") { + const row = await TaskList.findOne({ where: { task_list_id: target_id, deletedAt: null } }); + if (!row) { const err = new Error("Selected task list was not found."); err.status = 400; throw err; } + } else if (target_type === "course") { + const row = await Course.findOne({ where: { uuid: target_id, deletedAt: null } }); + if (!row) { const err = new Error("Selected course was not found."); err.status = 400; throw err; } + } else if (target_type === "tier_plan") { + const row = await mdl_TierPlans.findOne({ where: { plan_id: target_id, deletedAt: null } }); + if (!row) { const err = new Error("Selected tier plan was not found."); err.status = 400; throw err; } + } +} + +// Rank-0 (free) courses resolve like target_type: 'user' — everyone qualifies. +async function resolveCourseUserIds(courseUuid) { + const course = await Course.findOne({ where: { uuid: courseUuid, deletedAt: null }, attributes: ['course_id', 'subscription'] }); + if (!course) return []; + + // rank is BIGINT on CockroachDB — Sequelize returns it as a string, so normalize to Number. + const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] }); + const rankMap = Object.fromEntries(categories.map((c) => [c.slug, Number(c.rank)])); + const courseRank = rankMap[course.subscription] ?? Infinity; + + const userIds = new Set(); + + if (courseRank === 0) { + const users = await mdl_Users.findAll({ attributes: ['user_id'], where: { acc_type: 'user', deletedAt: null }, raw: true }); + users.forEach((u) => userIds.add(String(u.user_id))); + return [...userIds]; + } + + const qualifyingSlugs = Object.entries(rankMap).filter(([, rank]) => rank >= courseRank).map(([slug]) => slug); + if (qualifyingSlugs.length) { + const holders = await mdl_UserTiers.findAll({ + attributes: ['user_id'], + where: { status: 'active', tier: { [Op.in]: qualifyingSlugs } }, + raw: true, + }); + holders.forEach((h) => userIds.add(String(h.user_id))); + } + + const product = await mdl_Product.findOne({ where: { course_id: course.course_id } }); + if (product) { + const purchasers = await mdl_CoursePurchase.findAll({ + attributes: ['user_id'], + where: { + product_id: product.id, + status: 'completed', + [Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }], + }, + raw: true, + }); + purchasers.forEach((p) => userIds.add(String(p.user_id))); + } + + return [...userIds]; +} + +async function resolveTaskListUserIds(taskListId) { + const rows = await sequelize.query( + `SELECT DISTINCT ugm.user_id + FROM task_list_groups tlg + JOIN user_group_members ugm ON ugm.group_id = tlg.group_id AND ugm."deletedAt" IS NULL + WHERE tlg.task_list_id = :taskListId`, + { replacements: { taskListId }, type: QueryTypes.SELECT } + ); + return rows.map((r) => String(r.user_id)); +} + +// user_id → group_id, for deep-linking task_list broadcasts to /group/:groupId/view/:taskListId. +// First matching group wins if a user belongs to more than one group tied to the task list. +async function resolveTaskListUserGroups(taskListId) { + const rows = await sequelize.query( + `SELECT ugm.user_id, tlg.group_id + FROM task_list_groups tlg + JOIN user_group_members ugm ON ugm.group_id = tlg.group_id AND ugm."deletedAt" IS NULL + WHERE tlg.task_list_id = :taskListId`, + { replacements: { taskListId }, type: QueryTypes.SELECT } + ); + const map = {}; + for (const r of rows) { + const uid = String(r.user_id); + if (!(uid in map)) map[uid] = r.group_id; + } + return map; +} + +async function resolveTierPlanUserIds(planId) { + const holders = await mdl_UserTiers.findAll({ + attributes: ['user_id'], + where: { plan_id: planId, status: 'active' }, + raw: true, + }); + return holders.map((h) => String(h.user_id)); +} + +// Dispatcher for the 3 "scoped" target types only — 'admin'/'user'/'both' mean +// different things to different callers (e.g. notification broadcasts post to +// the shared admin bell feed for 'admin'; email broadcasts email every admin/ +// staff user instead), so those stay caller-specific rather than living here. +async function resolveTargetUserIds(target_type, target_id) { + if (target_type === 'task_list') return resolveTaskListUserIds(target_id); + if (target_type === 'course') return resolveCourseUserIds(target_id); + if (target_type === 'tier_plan') return resolveTierPlanUserIds(target_id); + return []; +} + +async function resolveAllUserIds({ transaction } = {}) { + const users = await mdl_Users.findAll({ + attributes: ['user_id'], + where: { acc_type: 'user', deletedAt: null }, + raw: true, + transaction, + }); + return users.map((u) => String(u.user_id)); +} + +module.exports = { + ALLOWED_TARGET_TYPES, + SCOPED_TARGET_TYPES, + validateTargetId, + resolveCourseUserIds, + resolveTaskListUserIds, + resolveTaskListUserGroups, + resolveTierPlanUserIds, + resolveTargetUserIds, + resolveAllUserIds, +}; diff --git a/utils/currency.util.js b/utils/currency.util.js deleted file mode 100644 index 5494ad6..0000000 --- a/utils/currency.util.js +++ /dev/null @@ -1,171 +0,0 @@ -/*********************************************************************************************************************************************************************** - * File Name: currency.util.js - * Type of Program: Utility - * Description: Currency formatting and resolution helpers for backend use (emails, receipts, notifications). - * - * All format functions accept an optional options object: { locale } - * locale — BCP 47 tag, defaults to 'en-US' - * - * USD is the platform's base/canonical currency. Plans may carry localized price - * overrides (plan_prices table). resolvePrice() applies the COALESCE logic: - * localized override wins → falls back to plan's base price + currency. - ***********************************************************************************************************************************************************************/ -'use strict'; - -// ─── Supported currencies ───────────────────────────────────────────────────── - -const SUPPORTED_CURRENCIES = [ - { code: 'USD', name: 'US Dollar', symbol: '$' }, - { code: 'EUR', name: 'Euro', symbol: '€' }, - { code: 'GBP', name: 'British Pound', symbol: '£' }, - { code: 'CNY', name: 'Chinese Yuan', symbol: '¥' }, - { code: 'JPY', name: 'Japanese Yen', symbol: '¥' }, - { code: 'PHP', name: 'Philippine Peso', symbol: '₱' }, - { code: 'KRW', name: 'South Korean Won', symbol: '₩' }, - { code: 'AUD', name: 'Australian Dollar', symbol: 'A$' }, - { code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' }, - { code: 'SGD', name: 'Singapore Dollar', symbol: 'S$' }, - { code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$'}, - { code: 'INR', name: 'Indian Rupee', symbol: '₹' }, - { code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM' }, - { code: 'THB', name: 'Thai Baht', symbol: '฿' }, - { code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp' }, - { code: 'TWD', name: 'Taiwan Dollar', symbol: 'NT$'}, - { code: 'VND', name: 'Vietnamese Dong', symbol: '₫' }, -]; - -const SUPPORTED_CURRENCY_CODES = new Set(SUPPORTED_CURRENCIES.map((c) => c.code)); - -function isSupported(code) { - return SUPPORTED_CURRENCY_CODES.has(code?.toUpperCase()); -} - -// ─── Formatting ─────────────────────────────────────────────────────────────── - -/** "¥1,299.00" / "$9.99" */ -function fmtCurrency(amount, currency = 'USD', { locale = 'en-US' } = {}) { - if (amount === null || amount === undefined) return '—'; - return new Intl.NumberFormat(locale, { - style: 'currency', - currency: currency ?? 'USD', - minimumFractionDigits: 2, - }).format(Number(amount)); -} - -// ─── Price resolution ───────────────────────────────────────────────────────── - -/** - * Returns the effective { price, currency } for a plan given a user's preferred currency. - * plan.prices must be eager-loaded (as: 'prices') for the override to be considered. - * Falls back to plan.price + plan.currency when no override exists. - */ -function resolvePrice(plan, preferredCurrency) { - if (!preferredCurrency || preferredCurrency === plan.currency) - return { price: Number(plan.price), currency: plan.currency }; - - const override = (plan.prices ?? []).find((p) => p.currency === preferredCurrency); - if (override) return { price: Number(override.price), currency: override.currency }; - - return { price: Number(plan.price), currency: plan.currency }; -} - -// ─── Exchange rate fetching ─────────────────────────────────────────────────── -// Uses frankfurter.app (ECB-backed, no API key, free). -// In-process cache with 1-hour TTL avoids hammering the API on every save. - -const _rateCache = new Map(); - -async function fetchExchangeRate(from, to) { - if (from === to) return 1; - const key = `${from}:${to}`; - const now = Date.now(); - const cached = _rateCache.get(key); - if (cached && cached.expiresAt > now) return cached.rate; - - try { - const res = await fetch( - `https://api.frankfurter.app/latest?from=${from}&to=${to}`, - { signal: AbortSignal.timeout(4000) }, - ); - if (!res.ok) return null; - const json = await res.json(); - const rate = json?.rates?.[to]; - if (!rate) return null; - _rateCache.set(key, { rate, expiresAt: now + 60 * 60 * 1000 }); // 1 h TTL - return rate; - } catch { - return null; - } -} - -// ─── Localized price validation ─────────────────────────────────────────────── -// Three zones relative to the market-rate conversion of the base price: -// -// pass → 85 % – 150 % of expected (green, saves normally) -// warn → 70 % – 85 % or 150 % – 300 % (saves with caution message) -// block → < 70 % or > 300 % (rejected — too far from market rate) -// -// If the exchange-rate API is unavailable the check is skipped (returns 'pass'). - -const PRICE_ZONES = { - LOWER_HARD: 0.70, - LOWER_WARN: 0.85, - UPPER_WARN: 1.50, - UPPER_HARD: 3.00, -}; - -async function validateLocalizedPrice(basePrice, baseCurrency, localizedPrice, targetCurrency) { - const rate = await fetchExchangeRate(baseCurrency, targetCurrency); - if (!rate) return { zone: 'pass', skipped: true }; - - const expected = Number(basePrice) * rate; - const entered = Number(localizedPrice); - const { LOWER_HARD, LOWER_WARN, UPPER_WARN, UPPER_HARD } = PRICE_ZONES; - - const hardMin = expected * LOWER_HARD; - const hardMax = expected * UPPER_HARD; - const warnMin = expected * LOWER_WARN; - const warnMax = expected * UPPER_WARN; - - const fmt = (n) => n.toFixed(2); - const rateStr = `1 ${baseCurrency} = ${rate} ${targetCurrency}`; - - if (entered < hardMin || entered > hardMax) { - return { - zone: 'block', - expected: fmt(expected), - hardMin: fmt(hardMin), hardMax: fmt(hardMax), - warnMin: fmt(warnMin), warnMax: fmt(warnMax), - message: `${fmt(entered)} ${targetCurrency} is too far from the current market rate (${rateStr}). ` + - `Acceptable range: ${fmt(hardMin)} – ${fmt(hardMax)} ${targetCurrency}.`, - }; - } - - if (entered < warnMin || entered > warnMax) { - return { - zone: 'warn', - expected: fmt(expected), - hardMin: fmt(hardMin), hardMax: fmt(hardMax), - warnMin: fmt(warnMin), warnMax: fmt(warnMax), - message: `${fmt(entered)} ${targetCurrency} is outside the suggested range (${rateStr}). ` + - `Suggested: ${fmt(warnMin)} – ${fmt(warnMax)} ${targetCurrency}. Saved with caution.`, - }; - } - - return { - zone: 'pass', - expected: fmt(expected), - hardMin: fmt(hardMin), hardMax: fmt(hardMax), - warnMin: fmt(warnMin), warnMax: fmt(warnMax), - }; -} - -module.exports = { - SUPPORTED_CURRENCIES, - isSupported, - fmtCurrency, - resolvePrice, - fetchExchangeRate, - validateLocalizedPrice, - PRICE_ZONES, -}; diff --git a/utils/renderTemplate.util.js b/utils/renderTemplate.util.js new file mode 100644 index 0000000..7dc9f77 --- /dev/null +++ b/utils/renderTemplate.util.js @@ -0,0 +1,21 @@ +/*********************************************************************************************************************************************************************** + * File Name: renderTemplate.util.js + * Type of Program: Utility + * Description: Plain-text {{placeholder}} substitution — no eval/Function, so + * admin-supplied HTML can never execute arbitrary JS. Unknown or + * missing keys resolve to an empty string rather than throwing. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 3, 2026 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const { renderTemplate } = require('../utils/renderTemplate.util'); + * renderTemplate('Hi {{name}}', { name: 'Ken' }); // "Hi Ken" + ***********************************************************************************************************************************************************************/ + +const renderTemplate = (str, data = {}) => + String(str ?? '').replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => { + const val = data[key]; + return (val === undefined || val === null) ? '' : String(val); + }); + +module.exports = { renderTemplate };