From a572c1e25f1de988f4b2b0c79287bc821f07cf70 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Fri, 3 Jul 2026 21:40:24 +0800 Subject: [PATCH] add: more commits Signed-off-by: Kenneth Obsequio --- controllers/admin/courses.controller.js | 6 +- .../notification_templates.controller.js | 82 ++++++ controllers/admin/task.controller.js | 16 +- controllers/auth.controller.js | 67 +++-- controllers/client/courses.controller.js | 9 +- controllers/client/tiers.controller.js | 17 +- cron/jobs/expire_user_tiers.cron.js | 23 +- cron/jobs/issue_certificates.cron.js | 8 +- cron/jobs/task_overdue.cron.js | 4 +- cron/jobs/user_notifications.cron.js | 4 +- data/notification_template_enrichers.data.js | 59 +++++ data/notifications.data.js | 242 ++---------------- ...703000008-create-notification-templates.js | 129 ++++++++++ .../notification_template.mdl.js | 46 ++++ routes/admin/admin.routes.js | 2 + routes/admin/notification_templates.routes.js | 12 + services/notificationTemplate.service.js | 52 ++++ 17 files changed, 476 insertions(+), 302 deletions(-) create mode 100644 controllers/admin/notification_templates.controller.js create mode 100644 data/notification_template_enrichers.data.js create mode 100644 database/migrations/20260703000008-create-notification-templates.js create mode 100644 models/notifications/notification_template.mdl.js create mode 100644 routes/admin/notification_templates.routes.js create mode 100644 services/notificationTemplate.service.js diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index e2e33a4..91197b8 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -12,7 +12,7 @@ const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); const { getFieldValues } = require("../../utils/fieldValues.util"); const logActivity = require('../../utils/logActivity.util'); const UserNotification = require('../../models/notifications/user_notification.mdl'); -const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); +const { renderNotification } = require('../../services/notificationTemplate.service'); // ── Models ──────────────────────────────────────────────────────────────────── @@ -1475,11 +1475,11 @@ exports.updateAssessment = async (req, res) => { where: { course_id: courseId }, attributes: ['title', 'uuid'], }); - const notify = NOTIFICATION_REGISTRY.assessment_updated.build({ + const notify = await renderNotification({ type: 'assessment_updated', data: { assessmentTitle: assessment.title, courseTitle: course?.title ?? null, courseUuid: course?.uuid ?? null, - }); + } }); const now = new Date(); await UserNotification.bulkCreate( inProgressSessions.map(({ user_id }) => ({ diff --git a/controllers/admin/notification_templates.controller.js b/controllers/admin/notification_templates.controller.js new file mode 100644 index 0000000..3052d97 --- /dev/null +++ b/controllers/admin/notification_templates.controller.js @@ -0,0 +1,82 @@ +'use strict'; + +const mdl_NotificationTemplate = require('../../models/notifications/notification_template.mdl'); +const R = require('../../utils/response.util'); +const logActivity = require('../../utils/logActivity.util'); + +// ─── GET /admin/notification-templates ───────────────────────────────────────── + +exports.getNotificationTemplates = async (req, res) => { + try { + const templates = await mdl_NotificationTemplate.findAll({ + order: [['notify_type', 'ASC'], ['type', 'ASC']], + }); + return R.success(res, 'Notification templates retrieved.', templates); + } catch (err) { + console.error('[ADMIN][GET NOTIFICATION TEMPLATES]', err); + return R.error(res, 'Could not retrieve notification templates.', 500); + } +}; + +// ─── GET /admin/notification-templates/:id ───────────────────────────────────── + +exports.getNotificationTemplate = async (req, res) => { + try { + const template = await mdl_NotificationTemplate.findByPk(req.params.id); + if (!template) return R.error(res, 'Notification template not found.', 404); + return R.success(res, 'Notification template retrieved.', template); + } catch (err) { + console.error('[ADMIN][GET NOTIFICATION TEMPLATE]', err); + return R.error(res, 'Could not retrieve notification template.', 500); + } +}; + +// ─── PUT /admin/notification-templates/:id ───────────────────────────────────── +// No create/delete endpoints — every row is is_system by definition (a new +// type needs a code call site before it means anything), so there is nothing +// valid to create or delete through this UI. + +exports.updateNotificationTemplate = async (req, res) => { + try { + const template = await mdl_NotificationTemplate.findByPk(req.params.id); + if (!template) return R.error(res, 'Notification template not found.', 404); + + const { label, title, message, publish } = req.body; + + if (title !== undefined && !title.trim()) return R.error(res, 'title cannot be empty.', 400); + if (message !== undefined && !message.trim()) return R.error(res, 'message cannot be empty.', 400); + + // "Publish" writes title/message straight to the live columns + // renderNotification() reads and clears any pending draft. A plain save + // (no publish flag) writes into draft_title/draft_message instead, so + // real notifications keep using the last-published content until an + // admin comes back and explicitly publishes again. + const isPublishing = publish === true || publish === 'true'; + const nextTitle = title ?? template.draft_title ?? template.title; + const nextMessage = message ?? template.draft_message ?? template.message; + + await template.update({ + label: label ?? template.label, + ...(isPublishing + ? { + status: 'sent', + title: nextTitle, + message: nextMessage, + draft_title: null, + draft_message: null, + last_sent_at: new Date(), + } + : { + draft_title: nextTitle, + draft_message: nextMessage, + }), + }); + + logActivity(req.user?.user_id, 'update_notification_template', { entityType: 'notification_template', entityId: template.notification_template_id, details: { type: template.type, published: isPublishing } }); + + return R.success(res, 'Notification template updated.', template); + } catch (err) { + console.error('[ADMIN][UPDATE NOTIFICATION TEMPLATE]', err); + return R.error(res, 'Could not update notification template.', 500); + } +}; diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index 195250f..797e329 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -14,7 +14,7 @@ const { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups } = requi 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 { renderNotification } = require('../../services/notificationTemplate.service'); const { adminExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes'); const R = require('../../utils/response.util'); @@ -652,14 +652,18 @@ exports.updateTask = async (req, res) => { if (members.length) { const now = new Date(); + // Title/message are identical for every member — render once, + // then vary only the per-member groupId in the data payload. + const notify = await renderNotification({ type: 'task_requirements_updated', data: { + taskName: full.name, + taskListId: task.task_list_id, + groupId: null, + } }); 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, - }), + ...notify, + data: { ...notify.data, groupId: group_id }, seen: false, createdAt: now, updatedAt: now, diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index 3480236..c6c7cd4 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -36,7 +36,7 @@ const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util') const { onUserRegistered } = require('../services/achievements.service'); const AdminNotification = require('../models/notifications/admin_notification.mdl'); const UserNotification = require('../models/notifications/user_notification.mdl'); -const { NOTIFICATION_REGISTRY } = require('../data/notifications.data'); +const { renderNotification } = require('../services/notificationTemplate.service'); const { sendEmail } = require('../services/email.service'); const buildSessionInfo = require('../utils/session_info.util'); const logActivity = require('../utils/logActivity.util'); @@ -109,20 +109,20 @@ exports.register = async (req, res) => { // Fire-and-forget: notify admins — explicit group or NOGRP fallback if (group) { - AdminNotification.create({ - ...NOTIFICATION_REGISTRY.user_registration.build({ - groupName: group.name, - groupCode: group.group_code, - userEmail: email, - }), - }).catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err)); + renderNotification({ type: 'user_registration', data: { + groupName: group.name, + groupCode: group.group_code, + userEmail: email, + } }) + .then(notify => AdminNotification.create(notify)) + .catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err)); } else if (enrollGroup) { - AdminNotification.create({ - ...NOTIFICATION_REGISTRY.nogrp_user_registered.build({ - userEmail: email, - regType: 'system', - }), - }).catch(err => console.error('[AUTH] Failed to emit nogrp_user_registered notification:', err)); + renderNotification({ type: 'nogrp_user_registered', data: { + userEmail: email, + regType: 'system', + } }) + .then(notify => AdminNotification.create(notify)) + .catch(err => console.error('[AUTH] Failed to emit nogrp_user_registered notification:', err)); } return R.success(res, 'Registration successful. Please check your email for the OTP.', { @@ -182,12 +182,12 @@ exports.verifyOTP = async (req, res) => { const notifications = [ { user_id: user.user_id, - ...NOTIFICATION_REGISTRY.welcome.build({ + ...(await renderNotification({ type: 'welcome', data: { groupName: grp?.name ?? null, groupCode: grp?.group_code ?? null, accType: user.acc_type, groupId: membership?.group_id ?? null, - }), + } })), createdAt: now, updatedAt: now, }, @@ -195,7 +195,7 @@ exports.verifyOTP = async (req, res) => { if (grp?.group_code === 'NOGRP') { notifications.push({ user_id: user.user_id, - ...NOTIFICATION_REGISTRY.nogrp_welcome.build(), + ...(await renderNotification({ type: 'nogrp_welcome', data: {} })), createdAt: now, updatedAt: now, }); @@ -405,28 +405,21 @@ exports.googleCallback = async (req, res) => { .catch(err => console.error('[AUTH] googleCallback: Failed to grant achievements:', err)); const _now = new Date(); - UserNotification.bulkCreate([ - { - user_id: user.user_id, - ...NOTIFICATION_REGISTRY.welcome.build({ groupName: null, groupCode: 'NOGRP', accType: 'user', groupId: noGrp?.group_id ?? null }), - createdAt: _now, - updatedAt: _now, - }, - { - user_id: user.user_id, - ...NOTIFICATION_REGISTRY.nogrp_welcome.build(), - createdAt: _now, - updatedAt: _now, - }, - ], { validate: false }) + Promise.all([ + renderNotification({ type: 'welcome', data: { groupName: null, groupCode: 'NOGRP', accType: 'user', groupId: noGrp?.group_id ?? null } }), + renderNotification({ type: 'nogrp_welcome', data: {} }), + ]).then(([welcomeNotify, nogrpNotify]) => UserNotification.bulkCreate([ + { user_id: user.user_id, ...welcomeNotify, createdAt: _now, updatedAt: _now }, + { user_id: user.user_id, ...nogrpNotify, createdAt: _now, updatedAt: _now }, + ], { validate: false })) .catch(err => console.error('[AUTH] googleCallback: Failed to emit welcome notifications:', err)); - AdminNotification.create({ - ...NOTIFICATION_REGISTRY.nogrp_user_registered.build({ - userEmail: payload.email, - regType: 'google', - }), - }).catch(err => console.error('[AUTH] googleCallback: Failed to emit nogrp_user_registered notification:', err)); + renderNotification({ type: 'nogrp_user_registered', data: { + userEmail: payload.email, + regType: 'google', + } }) + .then(notify => AdminNotification.create(notify)) + .catch(err => console.error('[AUTH] googleCallback: Failed to emit nogrp_user_registered notification:', err)); } catch (err) { await t.rollback(); throw err; diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index ee18a23..b79ad3c 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -35,7 +35,7 @@ const { onCourseCompleted } = require('../../services/achievements.service' const PendingCertificate = require('../../models/courses/pending_certificate.mdl'); const Certificate = require('../../models/courses/certificate.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); -const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); +const { renderNotification } = require('../../services/notificationTemplate.service'); const notDeleted = { deletedAt: null }; @@ -956,10 +956,9 @@ exports.submitCourseAssessment = async (req, res) => { } // Immediate notification: course completed, certificate incoming - UserNotification.create({ - user_id, - ...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null }), - }).catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err)); + renderNotification({ type: 'course_completed', data: { courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null } }) + .then(notify => UserNotification.create({ user_id, ...notify })) + .catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err)); } return R.success(res, "Assessment submitted.", { diff --git a/controllers/client/tiers.controller.js b/controllers/client/tiers.controller.js index 89dcd73..34e0b3a 100644 --- a/controllers/client/tiers.controller.js +++ b/controllers/client/tiers.controller.js @@ -22,7 +22,7 @@ const { onTierActivated } = require('../../services/achievements.service'); const { Course } = require('../../models/courses/courses.mdl'); const paymentSvc = require('../../services/payment.service'); const R = require('../../utils/response.util'); -const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); +const { renderNotification } = require('../../services/notificationTemplate.service'); require('../../models/tiers/tier.associations'); @@ -49,14 +49,13 @@ exports.getMyTier = async (req, res) => { // ── Inline safety net: expire between cron ticks ────────────────────────── if (tier && tier.expires_at && new Date(tier.expires_at) <= new Date()) { await tier.update({ status: 'expired' }); - UserNotification.create({ - user_id: req.user.user_id, - ...NOTIFICATION_REGISTRY.tier_expired.build({ - tier: tier.tier, - label: tier.plan?.label ?? null, - planId: tier.plan?.plan_id ?? null, - }), - }).catch(() => {}); + renderNotification({ type: 'tier_expired', data: { + tier: tier.tier, + label: tier.plan?.label ?? null, + planId: tier.plan?.plan_id ?? null, + } }) + .then(notify => UserNotification.create({ user_id: req.user.user_id, ...notify })) + .catch(() => {}); return R.success(res, 'Active tier retrieved.', { tier: 'free', status: 'active', category: null, just_expired: true, }); diff --git a/cron/jobs/expire_user_tiers.cron.js b/cron/jobs/expire_user_tiers.cron.js index f55d405..8d40aa2 100644 --- a/cron/jobs/expire_user_tiers.cron.js +++ b/cron/jobs/expire_user_tiers.cron.js @@ -28,7 +28,7 @@ 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'); +const { getNotificationTemplate, renderNotificationContent } = require('../../services/notificationTemplate.service'); require('../../models/tiers/tier.associations'); @@ -73,18 +73,17 @@ async function run() { // 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 { + const template = await getNotificationTemplate('tier_expired'); + const notifications = expired.map((t) => ({ + user_id: t.user_id, + ...renderNotificationContent(template, { + tier: t.tier, + label: t.plan?.label ?? null, + planId: t.plan?.plan_id ?? null, + }), + })); + await UserNotification.bulkCreate(notifications, { ignoreDuplicates: true }); } catch (err) { console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err); diff --git a/cron/jobs/issue_certificates.cron.js b/cron/jobs/issue_certificates.cron.js index 684c53d..52799a2 100644 --- a/cron/jobs/issue_certificates.cron.js +++ b/cron/jobs/issue_certificates.cron.js @@ -32,7 +32,7 @@ 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 { getNotificationTemplate, renderNotificationContent } = require('../../services/notificationTemplate.service'); const { ensureCertificateRecord } = require('../../services/certificate-record.service'); async function run() { @@ -59,6 +59,10 @@ async function run() { console.log(`[CRON][ISSUE CERTS] Processing ${rows.length} pending certificate(s).`); + // Fetched once outside the loop — content differs per row (courseTitle), + // but there's no need to re-query the template for every row. + const certificateTemplate = notificationsEnabled ? await getNotificationTemplate('certificate_issued') : null; + for (const row of rows) { const { pending_id, user_id, course_uuid, course_title } = row; const achKey = `course_completed_${course_uuid}`; @@ -87,7 +91,7 @@ async function run() { if (notificationsEnabled) { await UserNotification.create({ user_id, - ...NOTIFICATION_REGISTRY.certificate_issued.build({ + ...renderNotificationContent(certificateTemplate, { courseTitle: course_title ?? '', courseUuid: course_uuid, }), diff --git a/cron/jobs/task_overdue.cron.js b/cron/jobs/task_overdue.cron.js index 7db14a3..15cc9ed 100644 --- a/cron/jobs/task_overdue.cron.js +++ b/cron/jobs/task_overdue.cron.js @@ -26,7 +26,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'); +const { renderNotification } = require('../../services/notificationTemplate.service'); // ─── The actual sweep ──────────────────────────────────────────────────────── async function run() { @@ -59,7 +59,7 @@ async function run() { if (settings && !settings.enabled) return; await AdminNotification.create( - NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount }) + await renderNotification({ type: 'task_overdue', data: { count: affectedCount } }) ); } catch (err) { console.error('[CRON][TASK OVERDUE] Failed to insert admin notification:', err); diff --git a/cron/jobs/user_notifications.cron.js b/cron/jobs/user_notifications.cron.js index 0de856f..3505c07 100644 --- a/cron/jobs/user_notifications.cron.js +++ b/cron/jobs/user_notifications.cron.js @@ -25,7 +25,7 @@ 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 { renderNotification } = require('../../services/notificationTemplate.service'); const WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback @@ -71,7 +71,7 @@ async function run() { const count = recentlyOverdue.length; const now = new Date(); - const notify = NOTIFICATION_REGISTRY.user_task_overdue.build({ count, task_list_ids: taskListIds }); + const notify = await renderNotification({ type: 'user_task_overdue', data: { count, task_list_ids: taskListIds } }); await UserNotification.bulkCreate( affectedUsers.map(({ user_id }) => ({ diff --git a/data/notification_template_enrichers.data.js b/data/notification_template_enrichers.data.js new file mode 100644 index 0000000..425f0fe --- /dev/null +++ b/data/notification_template_enrichers.data.js @@ -0,0 +1,59 @@ +/*********************************************************************************************************************************************************************** + * File Name: notification_template_enrichers.data.js + * Type of Program: Data / Registry + * Description: Admin-edited notification templates are plain text — no + * conditionals or expressions allowed. Any type that used to + * branch on data in JS (e.g. task_overdue's singular/plural + * wording) gets that branch precomputed here into flat + * placeholder keys BEFORE substitution, so the stored title/ + * message only ever needs straight {{key}} swaps. + * Mirrors data/email_template_enrichers.data.js. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 3, 2026 + ***********************************************************************************************************************************************************************/ +const { fmtDate } = require('../utils/datetime.util'); + +const ENRICHERS = { + task_overdue: (data) => ({ + ...data, + task_word: Number(data.count) === 1 ? 'task was' : 'tasks were', + }), + + user_task_overdue: (data) => ({ + ...data, + task_label: Number(data.count) === 1 ? '1 task has' : `${data.count} tasks have`, + }), + + task_reminder: (data) => ({ + ...data, + deadline: fmtDate(data.deadline), + }), + + welcome: (data) => { + const greeting = data.accType === 'admin' + ? 'Welcome, Administrator!' + : data.accType === 'staff' + ? 'Welcome to the Philproperties team!' + : 'Welcome to Philproperties!'; + return { + ...data, + greeting, + group_suffix: data.groupName ? ` You have been added to ${data.groupName}.` : '', + }; + }, + + assessment_updated: (data) => ({ + ...data, + assessmentTitle: data.assessmentTitle || 'Course Assessment', + courseTitle: data.courseTitle || 'your course', + }), + + tier_expired: (data) => ({ + ...data, + planLabel: data.label ?? data.tier, + }), +}; + +const enrichNotificationData = (type, data = {}) => (ENRICHERS[type] ? ENRICHERS[type](data) : data); + +module.exports = { enrichNotificationData }; diff --git a/data/notifications.data.js b/data/notifications.data.js index 75c0547..0bd9789 100644 --- a/data/notifications.data.js +++ b/data/notifications.data.js @@ -1,137 +1,38 @@ /*********************************************************************************************************************************************************************** * File Name: notifications.data.js * Type of Program: Data - * Description: Central registry of all notification types for both admin and - * client (user) notifications. + * Description: Registry of notification types that have no fixed, admin- + * editable wording — content is entirely supplied by the caller + * at trigger time, so there's nothing to template. * - * Each entry describes one notification type: + * Every other system-triggered notification type (task overdue, + * welcome, tier expired, etc.) has been moved to the + * notification_templates table — admin-editable, {{placeholder}}- + * based, rendered via services/notificationTemplate.service.js's + * renderNotification()/renderNotificationContent(). See + * controllers/admin/notification_templates.controller.js. + * + * Each entry here describes one notification type: * type {string} — stored in the DB 'type' column * scope {string} — 'admin' | 'user' | 'both' - * trigger {string} — what fires it (cron | event | manual) + * trigger {string} — what fires it (event | manual) * build {function} — takes a data payload, returns the object * ready to pass to AdminNotification.create() * or UserNotification.create() / bulkCreate() * - * To add a new notification type: - * 1. Add an entry in the relevant section below. - * 2. Call NOTIFICATION_REGISTRY..build(data) at the trigger - * site (controller, cron, service). - * No other changes needed. - * * Current types: - * Admin : task_overdue, user_registration, nogrp_user_registered - * User : task_requirements_updated, user_task_overdue, achievement, course_unlocked, - * course_completed, certificate_issued, task_reminder, announcement, - * nogrp_welcome, tier_expired + * User : achievement, announcement * Both : broadcast (admin-composed, sent via notification_broadcasts CRUD) * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 19, 2026 + * Date Modified: Jul. 3, 2026 — fixed-wording types moved into notification_templates ***********************************************************************************************************************************************************************/ 'use strict'; -const { fmtDate } = require('../utils/datetime.util'); - const NOTIFICATION_REGISTRY = { - // ───────────────────────────────────────────────────────────────────────── - // ADMIN notifications (scope: 'admin') - // ───────────────────────────────────────────────────────────────────────── - - // ── Task ────────────────────────────────────────────────────────────────── - task_overdue: { - type: 'task_overdue', - scope: 'admin', - trigger: 'cron', - build({ count, task_list_ids = [] }) { - return { - type: 'task_overdue', - title: 'Tasks Overdue', - message: `${count} task${count === 1 ? ' was' : 's were'} automatically marked as overdue.`, - data: { count, task_list_ids }, - }; - }, - }, - - // ── User Registration ───────────────────────────────────────────────────── - user_registration: { - type: 'user_registration', - scope: 'admin', - trigger: 'event', - build({ groupName, groupCode, userEmail }) { - return { - type: 'user_registration', - title: 'New User Registered', - message: `A new user registered in ${groupName}.`, - data: { groupName, groupCode, userEmail }, - }; - }, - }, - - // ── Unaffiliated User Registration ─────────────────────────────────────── - nogrp_user_registered: { - type: 'nogrp_user_registered', - scope: 'admin', - trigger: 'event', - build({ userEmail, regType }) { - return { - type: 'nogrp_user_registered', - title: 'New Unaffiliated User', - message: `A new user (${userEmail}) registered via ${regType} without a group code and was placed in the default group.`, - data: { userEmail, regType }, - }; - }, - }, - - // ───────────────────────────────────────────────────────────────────────── - // USER notifications (scope: 'user') - // ───────────────────────────────────────────────────────────────────────── - - // ── 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', - trigger: 'cron', - build({ count, task_list_ids = [] }) { - const label = count === 1 ? '1 task has' : `${count} tasks have`; - return { - type: 'task', - title: 'Tasks Overdue', - message: `${label} passed their deadline and been marked as overdue.`, - data: { count, task_list_ids }, - }; - }, - }, - - task_reminder: { - type: 'task', - scope: 'user', - trigger: 'cron', - build({ taskName, deadline, taskListId = null, groupId = null }) { - return { - type: 'task', - title: 'Task Deadline Approaching', - message: `"${taskName}" is due on ${fmtDate(deadline)}.`, - data: { taskName, deadline, taskListId, groupId }, - }; - }, - }, - - // ── Achievement ─────────────────────────────────────────────────────────── + // ── Achievement — title/message come from the achievement definition itself ─ achievement: { type: 'achievement', scope: 'user', @@ -146,100 +47,7 @@ const NOTIFICATION_REGISTRY = { }, }, - // ── Course ──────────────────────────────────────────────────────────────── - course_unlocked: { - type: 'course', - scope: 'user', - trigger: 'event', - build({ courseTitle, courseUuid = null }) { - return { - type: 'course', - title: 'New Course Available', - message: `"${courseTitle}" has been added to your learning library.`, - data: { courseTitle, courseUuid }, - }; - }, - }, - - course_completed: { - type: 'course', - scope: 'user', - trigger: 'event', - 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, courseUuid }, - }; - }, - }, - - certificate_issued: { - type: 'course', - scope: 'user', - trigger: 'cron', - build({ courseTitle, courseUuid }) { - return { - type: 'course', - title: 'Certificate Issued', - message: `Congratulations! Your certificate for "${courseTitle}" is ready.`, - data: { courseTitle, courseUuid }, - }; - }, - }, - - // ── Welcome ─────────────────────────────────────────────────────────────── - welcome: { - type: 'announcement', - scope: 'user', - trigger: 'event', - build({ groupName, groupCode, accType, groupId = null }) { - const greeting = accType === 'admin' - ? 'Welcome, Administrator!' - : accType === 'staff' - ? 'Welcome to the Philproperties team!' - : 'Welcome to Philproperties!'; - return { - type: 'announcement', - title: 'Welcome to Philproperties', - message: groupName ? `${greeting} You have been added to ${groupName}.` : greeting, - data: { groupName, groupCode, accType, groupId }, - }; - }, - }, - - // ── No-Group Welcome ────────────────────────────────────────────────────── - nogrp_welcome: { - type: 'announcement', - scope: 'user', - trigger: 'event', - build() { - return { - type: 'announcement', - title: "You're Not in a Group Yet", - message: 'You are currently in the default group. Contact an administrator to be assigned to your team.', - data: { groupCode: 'NOGRP' }, - }; - }, - }, - - // ── Assessment ──────────────────────────────────────────────────────────── - assessment_updated: { - type: 'assessment', - scope: 'user', - trigger: 'event', - 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, courseUuid }, - }; - }, - }, - - // ── Platform ────────────────────────────────────────────────────────────── + // ── Platform — title/body typed fresh by whoever calls this ───────────────── announcement: { type: 'announcement', scope: 'user', @@ -254,7 +62,8 @@ const NOTIFICATION_REGISTRY = { }, }, - // ── Broadcast (admin-composed, manual) ─────────────────────────────────── + // ── Broadcast (admin-composed, manual) — title/message typed per-send via + // the notification_broadcasts CRUD, not a fixed template ───────────────── broadcast: { type: 'announcement', scope: 'both', @@ -269,21 +78,6 @@ const NOTIFICATION_REGISTRY = { }, }, - // ── Tier ────────────────────────────────────────────────────────────────── - tier_expired: { - type: 'tier_expired', - scope: 'user', - trigger: 'cron', - 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, planId }, - }; - }, - }, - }; module.exports = { NOTIFICATION_REGISTRY }; diff --git a/database/migrations/20260703000008-create-notification-templates.js b/database/migrations/20260703000008-create-notification-templates.js new file mode 100644 index 0000000..e9608a8 --- /dev/null +++ b/database/migrations/20260703000008-create-notification-templates.js @@ -0,0 +1,129 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('notification_templates', { + notification_template_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + type: { type: Sequelize.STRING(100), allowNull: false, unique: true }, + notify_type: { type: Sequelize.STRING(64), allowNull: false }, + scope: { type: Sequelize.ENUM('admin', 'user', 'both'), allowNull: false }, + label: { type: Sequelize.STRING(150), allowNull: false }, + status: { type: Sequelize.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft' }, + title: { type: Sequelize.STRING(255), allowNull: true }, + message: { type: Sequelize.TEXT, allowNull: true }, + draft_title: { type: Sequelize.STRING(255), allowNull: true }, + draft_message: { type: Sequelize.TEXT, allowNull: true }, + last_sent_at: { type: Sequelize.DATE, allowNull: true }, + is_system: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + const now = new Date(); + + // Ported straight from data/notifications.data.js's NOTIFICATION_REGISTRY — + // seeded already-published (status: 'sent', content in the live title/message + // columns) because these types fire in production right now via cron/event + // triggers. Seeding them as drafts would make every trigger site throw + // "no published version yet" the moment this migration lands. + await queryInterface.bulkInsert('notification_templates', [ + { + type: 'task_overdue', notify_type: 'task_overdue', scope: 'admin', is_system: true, + label: 'Tasks Overdue (Admin)', status: 'sent', + title: 'Tasks Overdue', + message: '{{count}} {{task_word}} automatically marked as overdue.', + createdAt: now, updatedAt: now, + }, + { + type: 'user_registration', notify_type: 'user_registration', scope: 'admin', is_system: true, + label: 'New User Registered', status: 'sent', + title: 'New User Registered', + message: 'A new user registered in {{groupName}}.', + createdAt: now, updatedAt: now, + }, + { + type: 'nogrp_user_registered', notify_type: 'nogrp_user_registered', scope: 'admin', is_system: true, + label: 'New Unaffiliated User', status: 'sent', + title: 'New Unaffiliated User', + message: 'A new user ({{userEmail}}) registered via {{regType}} without a group code and was placed in the default group.', + createdAt: now, updatedAt: now, + }, + { + type: 'task_requirements_updated', notify_type: 'task', scope: 'user', is_system: true, + label: 'Task Requirements Updated', status: 'sent', + title: 'Task Updated', + message: 'The requirements for "{{taskName}}" have been updated by your administrator.', + createdAt: now, updatedAt: now, + }, + { + type: 'user_task_overdue', notify_type: 'task', scope: 'user', is_system: true, + label: 'Tasks Overdue (User)', status: 'sent', + title: 'Tasks Overdue', + message: '{{task_label}} passed their deadline and been marked as overdue.', + createdAt: now, updatedAt: now, + }, + { + type: 'task_reminder', notify_type: 'task', scope: 'user', is_system: true, + label: 'Task Deadline Approaching', status: 'sent', + title: 'Task Deadline Approaching', + message: '"{{taskName}}" is due on {{deadline}}.', + createdAt: now, updatedAt: now, + }, + { + type: 'course_unlocked', notify_type: 'course', scope: 'user', is_system: true, + label: 'New Course Available', status: 'sent', + title: 'New Course Available', + message: '"{{courseTitle}}" has been added to your learning library.', + createdAt: now, updatedAt: now, + }, + { + type: 'course_completed', notify_type: 'course', scope: 'user', is_system: true, + label: 'Course Completed', status: 'sent', + title: 'Course Completed', + message: 'Great job! You\'ve completed "{{courseTitle}}". Your certificate will be issued within the next hour.', + createdAt: now, updatedAt: now, + }, + { + type: 'certificate_issued', notify_type: 'course', scope: 'user', is_system: true, + label: 'Certificate Issued', status: 'sent', + title: 'Certificate Issued', + message: 'Congratulations! Your certificate for "{{courseTitle}}" is ready.', + createdAt: now, updatedAt: now, + }, + { + type: 'welcome', notify_type: 'announcement', scope: 'user', is_system: true, + label: 'Welcome Message', status: 'sent', + title: 'Welcome to Philproperties', + message: '{{greeting}}{{group_suffix}}', + createdAt: now, updatedAt: now, + }, + { + type: 'nogrp_welcome', notify_type: 'announcement', scope: 'user', is_system: true, + label: "Not in a Group Yet", status: 'sent', + title: "You're Not in a Group Yet", + message: 'You are currently in the default group. Contact an administrator to be assigned to your team.', + createdAt: now, updatedAt: now, + }, + { + type: 'assessment_updated', notify_type: 'assessment', scope: 'user', is_system: true, + label: 'Assessment Updated', status: 'sent', + title: 'Assessment Updated', + message: 'The administrator has updated the "{{assessmentTitle}}" in "{{courseTitle}}". Your current session is still valid — continue where you left off.', + createdAt: now, updatedAt: now, + }, + { + type: 'tier_expired', notify_type: 'tier_expired', scope: 'user', is_system: true, + label: 'Subscription Expired', status: 'sent', + title: 'Subscription Expired', + message: 'Your {{planLabel}} plan has expired. Renew to keep access.', + createdAt: now, updatedAt: now, + }, + ]); + }, + + async down(queryInterface) { + await queryInterface.dropTable('notification_templates'); + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_notification_templates_scope";`); + await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_notification_templates_status";`); + }, +}; diff --git a/models/notifications/notification_template.mdl.js b/models/notifications/notification_template.mdl.js new file mode 100644 index 0000000..cf49426 --- /dev/null +++ b/models/notifications/notification_template.mdl.js @@ -0,0 +1,46 @@ +/*********************************************************************************************************************************************************************** + * File Name: notification_template.mdl.js + * Type of Program: Model + * Description: Admin-managed catalog of system-triggered notification wording + * (title + message). Mirrors models/email_templates/email_templates.mdl.js. + * `is_system` rows are the built-in types referenced by `type` from + * services/notificationTemplate.service.js's renderNotification() + * callers (cron jobs, controllers) — protected from deletion by the + * admin controller (no create/delete endpoint at all, since a new + * type needs a code call site before it means anything). + * + * Publish workflow: `title`/`message` are the LIVE content — the + * only columns renderNotification() ever reads. Editing a 'sent' + * template writes to `draft_title`/`draft_message` instead, leaving + * live content untouched until an admin explicitly publishes again + * (see controllers/admin/notification_templates.controller.js). + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 3, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const mdl_NotificationTemplate = sequelize.define('NotificationTemplate', { + notification_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 }, + // Written into the delivered admin_notifications/user_notifications row's own + // `type` column (e.g. 'task', 'course', 'announcement') — distinct from the + // lookup key above, since several lookup keys share the same delivered type. + notify_type: { type: DataTypes.STRING(64), allowNull: false, label: 'Notify Type', hidden: false, order: 2, filterable: true }, + scope: { type: DataTypes.ENUM('admin', 'user', 'both'), allowNull: false, label: 'Scope', hidden: false, order: 3, filterable: true }, + label: { type: DataTypes.STRING(150), allowNull: false, label: 'Label', hidden: false, order: 4, filterable: true }, + status: { type: DataTypes.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft', label: 'Status', hidden: false, order: 5, filterable: true }, + title: { type: DataTypes.STRING(255), allowNull: true, label: 'Title', hidden: false, order: 6, filterable: false }, + message: { type: DataTypes.TEXT, allowNull: true, label: 'Message', hidden: false, order: 7, filterable: false }, + draft_title: { type: DataTypes.STRING(255), allowNull: true, label: 'Draft Title', hidden: false, order: 8, filterable: false }, + draft_message: { type: DataTypes.TEXT, allowNull: true, label: 'Draft Message', hidden: false, order: 9, filterable: false }, + last_sent_at: { type: DataTypes.DATE, allowNull: true, label: 'Last Published', hidden: false, order: 10, filterable: false }, + is_system: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'System', hidden: false, order: 11, filterable: true }, +}, { + tableName: 'notification_templates', + timestamps: true, + paranoid: false, +}); + +module.exports = mdl_NotificationTemplate; diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index 059922b..24a4e5c 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -43,6 +43,7 @@ const mediaRoutes = require('./media.routes'); const achievementsRoutes = require('./achievements.routes'); const emailTemplatesRoutes = require('./email_templates.routes'); const emailBroadcastsRoutes = require('./email_broadcasts.routes'); +const notificationTemplatesRoutes = require('./notification_templates.routes'); const activityCtrl = require('../../controllers/admin/user_activity.controller'); // ── Guards — applied to ALL admin routes ────────────────────────────────────── @@ -65,6 +66,7 @@ router.use('/advertisements', advertisementRoutes); router.use('/notifications', notificationRoutes); router.use('/notification-broadcasts', notificationBroadcastRoutes); router.use('/notification-settings', notificationSettingsRoutes); +router.use('/notification-templates', notificationTemplatesRoutes); router.use('/media', mediaRoutes); router.use('/achievements', achievementsRoutes); router.use('/email-templates', emailTemplatesRoutes); diff --git a/routes/admin/notification_templates.routes.js b/routes/admin/notification_templates.routes.js new file mode 100644 index 0000000..2a71096 --- /dev/null +++ b/routes/admin/notification_templates.routes.js @@ -0,0 +1,12 @@ +'use strict'; + +const router = require('express').Router(); +const ctrl = require('../../controllers/admin/notification_templates.controller'); + +// Auth + requireAdmin applied by admin.routes.js + +router.get('/', ctrl.getNotificationTemplates); +router.get('/:id', ctrl.getNotificationTemplate); +router.put('/:id', ctrl.updateNotificationTemplate); + +module.exports = router; diff --git a/services/notificationTemplate.service.js b/services/notificationTemplate.service.js new file mode 100644 index 0000000..e9af8fa --- /dev/null +++ b/services/notificationTemplate.service.js @@ -0,0 +1,52 @@ +/*********************************************************************************************************************************************************************** + * File Name: notificationTemplate.service.js + * Type of Program: Service + * Description: Renders system-triggered notification title/message from the + * notification_templates table (admin-editable, see + * controllers/admin/notification_templates.controller.js). + * Mirrors the template-loading half of services/email.service.js's + * sendEmail() — same "only live columns count as published" rule, + * same {{placeholder}} substitution via utils/renderTemplate.util.js. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 3, 2026 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const { renderNotification } = require('../services/notificationTemplate.service'); + * const notify = await renderNotification({ type: 'task_overdue', data: { count } }); + * await AdminNotification.create(notify); + * + * // Bulk/loop use — fetch once, render per-row without re-querying the DB: + * const { getNotificationTemplate, renderNotificationContent } = require('../services/notificationTemplate.service'); + * const template = await getNotificationTemplate('tier_expired'); + * const rows = expired.map((t) => ({ user_id: t.user_id, ...renderNotificationContent(template, { tier: t.tier, label: t.plan?.label ?? null }) })); + ***********************************************************************************************************************************************************************/ +const mdl_NotificationTemplate = require('../models/notifications/notification_template.mdl'); +const { enrichNotificationData } = require('../data/notification_template_enrichers.data'); +const { renderTemplate } = require('../utils/renderTemplate.util'); + +const getNotificationTemplate = async (type) => { + const template = await mdl_NotificationTemplate.findOne({ where: { type } }); + if (!template || !template.title || !template.message) { + throw new Error(`Notification template "${type}" has no published version yet`); + } + return template; +}; + +// Pure — no DB access. Use when a template has already been fetched once +// (e.g. reused across a bulk-create loop) to avoid a query per row. +const renderNotificationContent = (template, data = {}) => { + const enriched = enrichNotificationData(template.type, data); + return { + type: template.notify_type, + title: renderTemplate(template.title, enriched), + message: renderTemplate(template.message, enriched), + data, + }; +}; + +const renderNotification = async ({ type, data = {} }) => { + const template = await getNotificationTemplate(type); + return renderNotificationContent(template, data); +}; + +module.exports = { getNotificationTemplate, renderNotificationContent, renderNotification };