add: more commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 21:40:24 +08:00
parent 1d12f04967
commit a572c1e25f
17 changed files with 476 additions and 302 deletions
+3 -3
View File
@@ -12,7 +12,7 @@ const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
const { getFieldValues } = require("../../utils/fieldValues.util"); const { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { renderNotification } = require('../../services/notificationTemplate.service');
// ── Models ──────────────────────────────────────────────────────────────────── // ── Models ────────────────────────────────────────────────────────────────────
@@ -1475,11 +1475,11 @@ exports.updateAssessment = async (req, res) => {
where: { course_id: courseId }, where: { course_id: courseId },
attributes: ['title', 'uuid'], attributes: ['title', 'uuid'],
}); });
const notify = NOTIFICATION_REGISTRY.assessment_updated.build({ const notify = await renderNotification({ type: 'assessment_updated', data: {
assessmentTitle: assessment.title, assessmentTitle: assessment.title,
courseTitle: course?.title ?? null, courseTitle: course?.title ?? null,
courseUuid: course?.uuid ?? null, courseUuid: course?.uuid ?? null,
}); } });
const now = new Date(); const now = new Date();
await UserNotification.bulkCreate( await UserNotification.bulkCreate(
inProgressSessions.map(({ user_id }) => ({ inProgressSessions.map(({ user_id }) => ({
@@ -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);
}
};
+10 -6
View File
@@ -14,7 +14,7 @@ const { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups } = requi
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const 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 { adminExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
@@ -652,14 +652,18 @@ exports.updateTask = async (req, res) => {
if (members.length) { if (members.length) {
const now = new Date(); 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( await UserNotification.bulkCreate(
members.map(({ user_id, group_id }) => ({ members.map(({ user_id, group_id }) => ({
user_id, user_id,
...NOTIFICATION_REGISTRY.task_requirements_updated.build({ ...notify,
taskName: full.name, data: { ...notify.data, groupId: group_id },
taskListId: task.task_list_id,
groupId: group_id,
}),
seen: false, seen: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
+23 -30
View File
@@ -36,7 +36,7 @@ const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util')
const { onUserRegistered } = require('../services/achievements.service'); const { onUserRegistered } = require('../services/achievements.service');
const AdminNotification = require('../models/notifications/admin_notification.mdl'); const AdminNotification = require('../models/notifications/admin_notification.mdl');
const UserNotification = require('../models/notifications/user_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 { sendEmail } = require('../services/email.service');
const buildSessionInfo = require('../utils/session_info.util'); const buildSessionInfo = require('../utils/session_info.util');
const logActivity = require('../utils/logActivity.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 // Fire-and-forget: notify admins — explicit group or NOGRP fallback
if (group) { if (group) {
AdminNotification.create({ renderNotification({ type: 'user_registration', data: {
...NOTIFICATION_REGISTRY.user_registration.build({
groupName: group.name, groupName: group.name,
groupCode: group.group_code, groupCode: group.group_code,
userEmail: email, userEmail: email,
}), } })
}).catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err)); .then(notify => AdminNotification.create(notify))
.catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err));
} else if (enrollGroup) { } else if (enrollGroup) {
AdminNotification.create({ renderNotification({ type: 'nogrp_user_registered', data: {
...NOTIFICATION_REGISTRY.nogrp_user_registered.build({
userEmail: email, userEmail: email,
regType: 'system', regType: 'system',
}), } })
}).catch(err => console.error('[AUTH] Failed to emit nogrp_user_registered notification:', err)); .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.', { return R.success(res, 'Registration successful. Please check your email for the OTP.', {
@@ -182,12 +182,12 @@ exports.verifyOTP = async (req, res) => {
const notifications = [ const notifications = [
{ {
user_id: user.user_id, user_id: user.user_id,
...NOTIFICATION_REGISTRY.welcome.build({ ...(await renderNotification({ type: 'welcome', data: {
groupName: grp?.name ?? null, groupName: grp?.name ?? null,
groupCode: grp?.group_code ?? null, groupCode: grp?.group_code ?? null,
accType: user.acc_type, accType: user.acc_type,
groupId: membership?.group_id ?? null, groupId: membership?.group_id ?? null,
}), } })),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
@@ -195,7 +195,7 @@ exports.verifyOTP = async (req, res) => {
if (grp?.group_code === 'NOGRP') { if (grp?.group_code === 'NOGRP') {
notifications.push({ notifications.push({
user_id: user.user_id, user_id: user.user_id,
...NOTIFICATION_REGISTRY.nogrp_welcome.build(), ...(await renderNotification({ type: 'nogrp_welcome', data: {} })),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@@ -405,28 +405,21 @@ exports.googleCallback = async (req, res) => {
.catch(err => console.error('[AUTH] googleCallback: Failed to grant achievements:', err)); .catch(err => console.error('[AUTH] googleCallback: Failed to grant achievements:', err));
const _now = new Date(); const _now = new Date();
UserNotification.bulkCreate([ Promise.all([
{ renderNotification({ type: 'welcome', data: { groupName: null, groupCode: 'NOGRP', accType: 'user', groupId: noGrp?.group_id ?? null } }),
user_id: user.user_id, renderNotification({ type: 'nogrp_welcome', data: {} }),
...NOTIFICATION_REGISTRY.welcome.build({ groupName: null, groupCode: 'NOGRP', accType: 'user', groupId: noGrp?.group_id ?? null }), ]).then(([welcomeNotify, nogrpNotify]) => UserNotification.bulkCreate([
createdAt: _now, { user_id: user.user_id, ...welcomeNotify, createdAt: _now, updatedAt: _now },
updatedAt: _now, { user_id: user.user_id, ...nogrpNotify, createdAt: _now, updatedAt: _now },
}, ], { validate: false }))
{
user_id: user.user_id,
...NOTIFICATION_REGISTRY.nogrp_welcome.build(),
createdAt: _now,
updatedAt: _now,
},
], { validate: false })
.catch(err => console.error('[AUTH] googleCallback: Failed to emit welcome notifications:', err)); .catch(err => console.error('[AUTH] googleCallback: Failed to emit welcome notifications:', err));
AdminNotification.create({ renderNotification({ type: 'nogrp_user_registered', data: {
...NOTIFICATION_REGISTRY.nogrp_user_registered.build({
userEmail: payload.email, userEmail: payload.email,
regType: 'google', regType: 'google',
}), } })
}).catch(err => console.error('[AUTH] googleCallback: Failed to emit nogrp_user_registered notification:', err)); .then(notify => AdminNotification.create(notify))
.catch(err => console.error('[AUTH] googleCallback: Failed to emit nogrp_user_registered notification:', err));
} catch (err) { } catch (err) {
await t.rollback(); await t.rollback();
throw err; throw err;
+4 -5
View File
@@ -35,7 +35,7 @@ const { onCourseCompleted } = require('../../services/achievements.service'
const PendingCertificate = require('../../models/courses/pending_certificate.mdl'); const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
const Certificate = require('../../models/courses/certificate.mdl'); const Certificate = require('../../models/courses/certificate.mdl');
const UserNotification = require('../../models/notifications/user_notification.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 }; const notDeleted = { deletedAt: null };
@@ -956,10 +956,9 @@ exports.submitCourseAssessment = async (req, res) => {
} }
// Immediate notification: course completed, certificate incoming // Immediate notification: course completed, certificate incoming
UserNotification.create({ renderNotification({ type: 'course_completed', data: { courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null } })
user_id, .then(notify => UserNotification.create({ user_id, ...notify }))
...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null }), .catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err));
}).catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err));
} }
return R.success(res, "Assessment submitted.", { return R.success(res, "Assessment submitted.", {
+5 -6
View File
@@ -22,7 +22,7 @@ const { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl'); const { Course } = require('../../models/courses/courses.mdl');
const paymentSvc = require('../../services/payment.service'); const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { renderNotification } = require('../../services/notificationTemplate.service');
require('../../models/tiers/tier.associations'); require('../../models/tiers/tier.associations');
@@ -49,14 +49,13 @@ exports.getMyTier = async (req, res) => {
// ── Inline safety net: expire between cron ticks ────────────────────────── // ── Inline safety net: expire between cron ticks ──────────────────────────
if (tier && tier.expires_at && new Date(tier.expires_at) <= new Date()) { if (tier && tier.expires_at && new Date(tier.expires_at) <= new Date()) {
await tier.update({ status: 'expired' }); await tier.update({ status: 'expired' });
UserNotification.create({ renderNotification({ type: 'tier_expired', data: {
user_id: req.user.user_id,
...NOTIFICATION_REGISTRY.tier_expired.build({
tier: tier.tier, tier: tier.tier,
label: tier.plan?.label ?? null, label: tier.plan?.label ?? null,
planId: tier.plan?.plan_id ?? null, planId: tier.plan?.plan_id ?? null,
}), } })
}).catch(() => {}); .then(notify => UserNotification.create({ user_id: req.user.user_id, ...notify }))
.catch(() => {});
return R.success(res, 'Active tier retrieved.', { return R.success(res, 'Active tier retrieved.', {
tier: 'free', status: 'active', category: null, just_expired: true, tier: 'free', status: 'active', category: null, just_expired: true,
}); });
+7 -8
View File
@@ -28,7 +28,7 @@ const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); const 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'); require('../../models/tiers/tier.associations');
@@ -73,18 +73,17 @@ async function run() {
// Status flip above always happens — only this step is skippable via settings. // Status flip above always happens — only this step is skippable via settings.
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'expireUserTiers' } }); const settings = await CronNotificationSetting.findOne({ where: { job_name: 'expireUserTiers' } });
if (!settings || settings.enabled) { if (!settings || settings.enabled) {
const notifications = expired.map((t) => try {
NOTIFICATION_REGISTRY.tier_expired.build({ const template = await getNotificationTemplate('tier_expired');
const notifications = expired.map((t) => ({
user_id: t.user_id,
...renderNotificationContent(template, {
tier: t.tier, tier: t.tier,
label: t.plan?.label ?? null, label: t.plan?.label ?? null,
planId: t.plan?.plan_id ?? null, planId: t.plan?.plan_id ?? null,
}) }),
).map((payload, i) => ({
user_id: expired[i].user_id,
...payload,
})); }));
try {
await UserNotification.bulkCreate(notifications, { ignoreDuplicates: true }); await UserNotification.bulkCreate(notifications, { ignoreDuplicates: true });
} catch (err) { } catch (err) {
console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err); console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err);
+6 -2
View File
@@ -32,7 +32,7 @@ const PendingCertificate = require('../../models/courses/pending_certificate.mdl
const mdl_Achievements = require('../../models/users/achievements.mdl'); const mdl_Achievements = require('../../models/users/achievements.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); const 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'); const { ensureCertificateRecord } = require('../../services/certificate-record.service');
async function run() { async function run() {
@@ -59,6 +59,10 @@ async function run() {
console.log(`[CRON][ISSUE CERTS] Processing ${rows.length} pending certificate(s).`); 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) { for (const row of rows) {
const { pending_id, user_id, course_uuid, course_title } = row; const { pending_id, user_id, course_uuid, course_title } = row;
const achKey = `course_completed_${course_uuid}`; const achKey = `course_completed_${course_uuid}`;
@@ -87,7 +91,7 @@ async function run() {
if (notificationsEnabled) { if (notificationsEnabled) {
await UserNotification.create({ await UserNotification.create({
user_id, user_id,
...NOTIFICATION_REGISTRY.certificate_issued.build({ ...renderNotificationContent(certificateTemplate, {
courseTitle: course_title ?? '', courseTitle: course_title ?? '',
courseUuid: course_uuid, courseUuid: course_uuid,
}), }),
+2 -2
View File
@@ -26,7 +26,7 @@ const { Op } = require('sequelize');
const { Task } = require('../../models/task/task.mdl'); const { Task } = require('../../models/task/task.mdl');
const AdminNotification = require('../../models/notifications/admin_notification.mdl'); const AdminNotification = require('../../models/notifications/admin_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { renderNotification } = require('../../services/notificationTemplate.service');
// ─── The actual sweep ──────────────────────────────────────────────────────── // ─── The actual sweep ────────────────────────────────────────────────────────
async function run() { async function run() {
@@ -59,7 +59,7 @@ async function run() {
if (settings && !settings.enabled) return; if (settings && !settings.enabled) return;
await AdminNotification.create( await AdminNotification.create(
NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount }) await renderNotification({ type: 'task_overdue', data: { count: affectedCount } })
); );
} catch (err) { } catch (err) {
console.error('[CRON][TASK OVERDUE] Failed to insert admin notification:', err); console.error('[CRON][TASK OVERDUE] Failed to insert admin notification:', err);
+2 -2
View File
@@ -25,7 +25,7 @@ const sequelize = require('../../config/db.config');
const { Task } = require('../../models/task/task.mdl'); const { Task } = require('../../models/task/task.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); const 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 const WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback
@@ -71,7 +71,7 @@ async function run() {
const count = recentlyOverdue.length; const count = recentlyOverdue.length;
const now = new Date(); 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( await UserNotification.bulkCreate(
affectedUsers.map(({ user_id }) => ({ affectedUsers.map(({ user_id }) => ({
@@ -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 };
+18 -224
View File
@@ -1,137 +1,38 @@
/*********************************************************************************************************************************************************************** /***********************************************************************************************************************************************************************
* File Name: notifications.data.js * File Name: notifications.data.js
* Type of Program: Data * Type of Program: Data
* Description: Central registry of all notification types for both admin and * Description: Registry of notification types that have no fixed, admin-
* client (user) notifications. * 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 * type {string} — stored in the DB 'type' column
* scope {string} — 'admin' | 'user' | 'both' * 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 * build {function} — takes a data payload, returns the object
* ready to pass to AdminNotification.create() * ready to pass to AdminNotification.create()
* or UserNotification.create() / bulkCreate() * or UserNotification.create() / bulkCreate()
* *
* To add a new notification type:
* 1. Add an entry in the relevant section below.
* 2. Call NOTIFICATION_REGISTRY.<key>.build(data) at the trigger
* site (controller, cron, service).
* No other changes needed.
*
* Current types: * Current types:
* Admin : task_overdue, user_registration, nogrp_user_registered * User : achievement, announcement
* 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) * Both : broadcast (admin-composed, sent via notification_broadcasts CRUD)
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026 * Date Created: Jun. 19, 2026
* Date Modified: Jul. 3, 2026 — fixed-wording types moved into notification_templates
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
'use strict'; 'use strict';
const { fmtDate } = require('../utils/datetime.util');
const NOTIFICATION_REGISTRY = { const NOTIFICATION_REGISTRY = {
// ───────────────────────────────────────────────────────────────────────── // ── Achievement — title/message come from the achievement definition itself ─
// 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: { achievement: {
type: 'achievement', type: 'achievement',
scope: 'user', scope: 'user',
@@ -146,100 +47,7 @@ const NOTIFICATION_REGISTRY = {
}, },
}, },
// ── Course ──────────────────────────────────────────────────────────────── // ── Platform — title/body typed fresh by whoever calls this ─────────────────
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 ──────────────────────────────────────────────────────────────
announcement: { announcement: {
type: 'announcement', type: 'announcement',
scope: 'user', 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: { broadcast: {
type: 'announcement', type: 'announcement',
scope: 'both', 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 }; module.exports = { NOTIFICATION_REGISTRY };
@@ -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";`);
},
};
@@ -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;
+2
View File
@@ -43,6 +43,7 @@ const mediaRoutes = require('./media.routes');
const achievementsRoutes = require('./achievements.routes'); const achievementsRoutes = require('./achievements.routes');
const emailTemplatesRoutes = require('./email_templates.routes'); const emailTemplatesRoutes = require('./email_templates.routes');
const emailBroadcastsRoutes = require('./email_broadcasts.routes'); const emailBroadcastsRoutes = require('./email_broadcasts.routes');
const notificationTemplatesRoutes = require('./notification_templates.routes');
const activityCtrl = require('../../controllers/admin/user_activity.controller'); const activityCtrl = require('../../controllers/admin/user_activity.controller');
// ── Guards — applied to ALL admin routes ────────────────────────────────────── // ── Guards — applied to ALL admin routes ──────────────────────────────────────
@@ -65,6 +66,7 @@ router.use('/advertisements', advertisementRoutes);
router.use('/notifications', notificationRoutes); router.use('/notifications', notificationRoutes);
router.use('/notification-broadcasts', notificationBroadcastRoutes); router.use('/notification-broadcasts', notificationBroadcastRoutes);
router.use('/notification-settings', notificationSettingsRoutes); router.use('/notification-settings', notificationSettingsRoutes);
router.use('/notification-templates', notificationTemplatesRoutes);
router.use('/media', mediaRoutes); router.use('/media', mediaRoutes);
router.use('/achievements', achievementsRoutes); router.use('/achievements', achievementsRoutes);
router.use('/email-templates', emailTemplatesRoutes); router.use('/email-templates', emailTemplatesRoutes);
@@ -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;
+52
View File
@@ -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 };