Add sessionHolding template, unique 8-digit codes, reason slot for sessionCancelled, and disable unused notifications

This commit is contained in:
2026-08-21 10:44:51 +03:30
parent 5328f36f06
commit 091e519280
28 changed files with 2180 additions and 280 deletions
+251
View File
@@ -0,0 +1,251 @@
'use strict';
const { CHANNELS } = require('../../utils/messagingChannels');
const NOTIFICATION_CATEGORIES = [
{ key: 'users', label: 'کاربران' },
{ key: 'classes', label: 'کلاس‌ها و جلسات' },
{ key: 'payments', label: 'پرداخت‌ها' },
{ key: 'registrations', label: 'ثبت‌نام و درخواست‌ها' },
{ key: 'certificates', label: 'گواهینامه‌ها' }
];
const NOTIFICATION_ACTION_DEFS = [
{
key: 'accountCreated',
label: 'ایجاد حساب کاربری',
description: 'ارسال نام کاربری و رمز عبور پس از ایجاد حساب',
category: 'users',
relatedEvent: 'user.created',
smsTemplateKey: 'accountCreated',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'passwordReset',
label: 'بازنشانی رمز عبور',
description: 'ارسال رمز عبور جدید پس از بازنشانی توسط مدیر',
category: 'users',
relatedEvent: 'user.password_reset',
smsTemplateKey: 'passwordReset',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'classRegistered',
label: 'ثبت‌نام در کلاس',
description: 'اطلاع‌رسانی پس از ثبت‌نام کارآموز در کلاس',
category: 'classes',
relatedEvent: 'user.enrolled',
smsTemplateKey: 'classRegistered',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'classReminder',
label: 'یادآوری کلاس',
description: 'یادآوری خودکار حدود ۳۰ دقیقه قبل از شروع جلسه',
category: 'classes',
relatedEvent: 'session.reminder',
smsTemplateKey: 'classReminder',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'sessionCancelled',
label: 'لغو جلسه',
description: 'اطلاع‌رسانی لغو جلسه به کارآموزان ثبت‌نام‌شده',
category: 'classes',
relatedEvent: 'session.cancelled',
smsTemplateKey: 'sessionCancelled',
defaultChannels: { sms: true, email: true, bot: false }
},
{
key: 'sessionHolding',
label: 'برگزاری جلسه طبق برنامه',
description: 'اطلاع‌رسانی برگزاری جلسه طبق برنامه به کارآموزان',
category: 'classes',
relatedEvent: 'session.holding',
smsTemplateKey: 'sessionHolding',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'invoiceCreated',
label: 'ایجاد صورتحساب',
description: 'اطلاع‌رسانی صدور صورتحساب جدید',
category: 'payments',
relatedEvent: 'payment.created',
smsTemplateKey: 'invoiceCreated',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'paymentStatusChanged',
label: 'تغییر وضعیت پرداخت',
description: 'اطلاع‌رسانی تغییر وضعیت صورتحساب (پرداخت‌شده، معوق و …)',
category: 'payments',
relatedEvent: 'payment.status_changed',
smsTemplateKey: 'paymentStatusChanged',
defaultChannels: { sms: true, email: true, bot: false }
},
{
key: 'paymentReminder',
label: 'یادآوری سررسید پرداخت',
description: 'یادآوری خودکار پرداخت‌های نزدیک به سررسید',
category: 'payments',
relatedEvent: 'payment.reminder_due',
smsTemplateKey: 'paymentReminder',
defaultChannels: { sms: true, email: true, bot: false }
},
{
key: 'transactionRecorded',
label: 'ثبت تراکنش',
description: 'اطلاع‌رسانی ثبت یا تأیید تراکنش پرداخت',
category: 'payments',
relatedEvent: 'payment.transaction_added',
smsTemplateKey: 'transactionRecorded',
defaultChannels: { sms: true, email: false, bot: false }
},
{
key: 'pendingRegistration',
label: 'دریافت درخواست ثبت‌نام',
description: 'تأیید دریافت درخواست ثبت‌نام آنلاین توسط کارآموز',
category: 'registrations',
relatedEvent: 'pending.registration_received',
smsTemplateKey: 'pendingRegistration',
defaultChannels: { sms: false, email: false, bot: false }
},
{
key: 'classRequestApproved',
label: 'تأیید درخواست تشکیل کلاس',
description: 'اطلاع‌رسانی تأیید درخواست تشکیل کلاس جدید',
category: 'registrations',
relatedEvent: 'class_request.approved',
smsTemplateKey: 'classRequestApproved',
defaultChannels: { sms: false, email: false, bot: false }
},
{
key: 'classRequestRejected',
label: 'رد درخواست ثبت‌نام',
description: 'اطلاع‌رسانی رد درخواست ثبت‌نام یا تشکیل کلاس',
category: 'registrations',
relatedEvent: 'class_request.rejected',
smsTemplateKey: 'classRequestRejected',
defaultChannels: { sms: false, email: false, bot: false }
},
{
key: 'certificateIssued',
label: 'صدور گواهینامه',
description: 'اطلاع‌رسانی بارگذاری یا صدور گواهینامه',
category: 'certificates',
relatedEvent: 'certificate.issued',
smsTemplateKey: 'certificateIssued',
defaultChannels: { sms: true, email: true, bot: false }
}
];
const NOTIFICATION_ACTION_KEYS = NOTIFICATION_ACTION_DEFS.map((def) => def.key);
const defaultActionChannels = (def) => ({
sms: def?.defaultChannels?.sms !== false,
email: def?.defaultChannels?.email === true,
bot: def?.defaultChannels?.bot === true
});
const emptyNotificationSettingsMap = () => {
const map = {};
NOTIFICATION_ACTION_DEFS.forEach((def) => {
map[def.key] = defaultActionChannels(def);
});
return map;
};
const normalizeChannelValue = (value, fallback) => {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
const normalized = String(value).trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
return fallback;
};
const normalizeStoredActionEntry = (raw, def) => {
const defaults = defaultActionChannels(def);
if (raw == null || typeof raw !== 'object') {
return { ...defaults };
}
return {
sms: normalizeChannelValue(raw.sms, defaults.sms),
email: normalizeChannelValue(raw.email, defaults.email),
bot: normalizeChannelValue(raw.bot, defaults.bot)
};
};
const mergeNotificationSettings = (storedMap = {}, incomingMap = null) => {
const next = {};
for (const def of NOTIFICATION_ACTION_DEFS) {
const stored = normalizeStoredActionEntry(storedMap[def.key], def);
const incoming = incomingMap && Object.prototype.hasOwnProperty.call(incomingMap, def.key)
? incomingMap[def.key]
: null;
if (incoming && typeof incoming === 'object') {
next[def.key] = {
sms: incoming.sms !== undefined ? normalizeChannelValue(incoming.sms, stored.sms) : stored.sms,
email: incoming.email !== undefined ? normalizeChannelValue(incoming.email, stored.email) : stored.email,
bot: incoming.bot !== undefined ? normalizeChannelValue(incoming.bot, stored.bot) : stored.bot
};
} else {
next[def.key] = stored;
}
}
return next;
};
const toPublicNotificationSettings = (storedMap = {}) => {
return NOTIFICATION_CATEGORIES.map((category) => ({
key: category.key,
label: category.label,
actions: NOTIFICATION_ACTION_DEFS
.filter((def) => def.category === category.key)
.map((def) => {
const channels = normalizeStoredActionEntry(storedMap[def.key], def);
return {
key: def.key,
label: def.label,
description: def.description,
relatedEvent: def.relatedEvent,
smsTemplateKey: def.smsTemplateKey,
channels: {
sms: channels.sms,
email: channels.email,
bot: channels.bot
}
};
})
}));
};
const parseIncomingNotificationSettings = (raw) => {
if (raw === undefined) return undefined;
if (raw == null) return emptyNotificationSettingsMap();
const incomingMap = Array.isArray(raw)
? Object.fromEntries(
raw.flatMap((group) => (group?.actions || []).map((action) => [action.key, action.channels || action]))
)
: raw;
return mergeNotificationSettings({}, incomingMap);
};
const findActionDef = (actionKey) => NOTIFICATION_ACTION_DEFS.find((def) => def.key === actionKey);
module.exports = {
CHANNELS,
NOTIFICATION_CATEGORIES,
NOTIFICATION_ACTION_DEFS,
NOTIFICATION_ACTION_KEYS,
emptyNotificationSettingsMap,
mergeNotificationSettings,
toPublicNotificationSettings,
parseIncomingNotificationSettings,
normalizeStoredActionEntry,
findActionDef
};
+4
View File
@@ -27,6 +27,10 @@ const settingSchema = new mongoose.Schema({
botEnabled: {
type: Boolean,
default: true
},
notificationSettings: {
type: mongoose.Schema.Types.Mixed,
default: {}
}
}, {
timestamps: true,
+50 -3
View File
@@ -15,6 +15,17 @@ const {
getPublicMessaging,
invalidateMessagingCache
} = require('./messagingFlags');
const {
emptyNotificationSettingsMap,
mergeNotificationSettings,
parseIncomingNotificationSettings,
NOTIFICATION_ACTION_KEYS
} = require('./notificationActions');
const {
getPublicNotificationSettings,
invalidateNotificationSettingsCache,
readStoredMap: readNotificationStoredMap
} = require('../../utils/notificationSettings');
const emptyTemplateMap = () => {
const map = {};
@@ -64,11 +75,33 @@ const toPublicTemplates = (storedMap) => {
};
const getSettings = async () => {
const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
let doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
if (!doc) {
const created = await Setting.create({
key: SETTINGS_KEY,
smsTemplates: emptyTemplateMap(),
notificationSettings: emptyNotificationSettingsMap()
});
doc = created.toObject();
} else {
const mergedNotifications = mergeNotificationSettings(readNotificationStoredMap(doc));
const storedKeys = Object.keys(readNotificationStoredMap(doc));
const missingAction = NOTIFICATION_ACTION_KEYS.some((key) => !storedKeys.includes(key));
if (missingAction || storedKeys.length === 0) {
await Setting.updateOne(
{ key: SETTINGS_KEY },
{ $set: { notificationSettings: mergedNotifications } }
);
doc = { ...doc, notificationSettings: mergedNotifications };
invalidateNotificationSettingsCache();
}
}
const storedMap = readStoredMap(doc);
return {
smsTemplates: toPublicTemplates(storedMap),
messaging: getPublicMessaging(doc)
messaging: getPublicMessaging(doc),
notificationSettings: await getPublicNotificationSettings(doc)
};
};
@@ -150,13 +183,27 @@ const saveSettings = async (body = {}) => {
if (incomingMessaging.botEnabled !== undefined) doc.botEnabled = incomingMessaging.botEnabled;
}
const incomingNotificationSettings = parseIncomingNotificationSettings(body.notificationSettings);
if (incomingNotificationSettings !== undefined) {
const storedNotificationMap = existing
? readNotificationStoredMap(existing.toObject ? existing.toObject() : existing)
: emptyNotificationSettingsMap();
const nextNotificationMap = mergeNotificationSettings(storedNotificationMap, incomingNotificationSettings);
doc.set('notificationSettings', JSON.parse(JSON.stringify(nextNotificationMap)));
doc.markModified('notificationSettings');
} else if (!existing) {
doc.set('notificationSettings', emptyNotificationSettingsMap());
}
await doc.save();
invalidateMessagingCache();
invalidateNotificationSettingsCache();
const saved = await Setting.findOne({ key: SETTINGS_KEY }).lean();
return {
smsTemplates: toPublicTemplates(readStoredMap(saved)),
messaging: getPublicMessaging(saved)
messaging: getPublicMessaging(saved),
notificationSettings: await getPublicNotificationSettings(saved)
};
};
+138 -4
View File
@@ -9,7 +9,8 @@ const SMS_TEMPLATE_DEFS = [
{ key: 'username', label: 'نام کاربری', defaultName: 'user' },
{ key: 'password', label: 'رمز عبور', defaultName: 'password' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'name' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
]
},
{
@@ -23,7 +24,8 @@ const SMS_TEMPLATE_DEFS = [
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
]
},
{
@@ -33,13 +35,17 @@ const SMS_TEMPLATE_DEFS = [
slots: [
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
{ key: 'time', label: 'ساعت', defaultName: 'time' },
{ key: 'classTime', label: 'ساعت کلاس', defaultName: 'classTime' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' },
{ key: 'place', label: 'مکان', defaultName: 'place' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'fullName' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
]
},
{
@@ -53,7 +59,135 @@ const SMS_TEMPLATE_DEFS = [
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' }
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }
]
},
{
key: 'passwordReset',
label: 'بازنشانی رمز عبور',
envKey: 'SMS_TEMPLATE_PASSWORD_RESET',
slots: [
{ key: 'username', label: 'نام کاربری', defaultName: 'user' },
{ key: 'password', label: 'رمز عبور', defaultName: 'password' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'name' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
]
},
{
key: 'paymentStatusChanged',
label: 'تغییر وضعیت پرداخت',
envKey: 'SMS_TEMPLATE_PAYMENT_STATUS_CHANGED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'status', label: 'وضعیت', defaultName: 'status' },
{ key: 'amount', label: 'مبلغ', defaultName: 'amount' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
{ key: 'course', label: 'نام دوره', defaultName: 'course' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'paymentReminder',
label: 'یادآوری سررسید پرداخت',
envKey: 'SMS_TEMPLATE_PAYMENT_REMINDER',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'amount', label: 'مبلغ باقی‌مانده', defaultName: 'amount' },
{ key: 'dueDate', label: 'تاریخ سررسید', defaultName: 'dueDate' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
{ key: 'course', label: 'نام دوره', defaultName: 'course' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'transactionRecorded',
label: 'ثبت تراکنش',
envKey: 'SMS_TEMPLATE_TRANSACTION_RECORDED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'amount', label: 'مبلغ', defaultName: 'amount' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
{ key: 'transactionCode', label: 'کد تراکنش', defaultName: 'transactionCode' },
{ key: 'receiptNumber', label: 'شماره رسید', defaultName: 'receiptNumber' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'sessionCancelled',
label: 'لغو جلسه',
envKey: 'SMS_TEMPLATE_SESSION_CANCELLED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' },
{ key: 'reason', label: 'دلیل لغو', defaultName: 'reason' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'sessionHolding',
label: 'برگزاری جلسه طبق برنامه',
envKey: 'SMS_TEMPLATE_SESSION_HOLDING',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' },
{ key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' },
{ key: 'classTime', label: 'ساعت کلاس', defaultName: 'CLASSTIME' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
{ key: 'place', label: 'مکان', defaultName: 'place' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'classRequestApproved',
label: 'تأیید درخواست تشکیل کلاس',
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_APPROVED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'classRequestRejected',
label: 'رد درخواست ثبت‌نام',
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_REJECTED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'reason', label: 'دلیل', defaultName: 'reason' },
{ key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'pendingRegistration',
label: 'دریافت درخواست ثبت‌نام',
envKey: 'SMS_TEMPLATE_PENDING_REGISTRATION',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'certificateIssued',
label: 'صدور گواهینامه',
envKey: 'SMS_TEMPLATE_CERTIFICATE_ISSUED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'certificateTitle', label: 'عنوان گواهینامه', defaultName: 'certificateTitle' },
{ key: 'certificateCode', label: 'کد گواهینامه', defaultName: 'certificateCode' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
}
];
+16
View File
@@ -76,4 +76,20 @@ describe('SMS template variables', () => {
assert.ok(slots.includes('username'));
assert.ok(slots.includes('password'));
});
it('exposes reason slot on the sessionCancelled template', () => {
const def = SMS_TEMPLATE_DEFS.find((item) => item.key === 'sessionCancelled');
const slots = (def?.slots || []).map((slot) => slot.key);
assert.ok(slots.includes('reason'));
});
it('defines the sessionHolding template with all expected slots', () => {
const def = SMS_TEMPLATE_DEFS.find((item) => item.key === 'sessionHolding');
assert.ok(def, 'sessionHolding template definition missing');
const slots = (def.slots || []).map((slot) => slot.key);
const expected = ['fullName', 'topic', 'className', 'sessionDate', 'classTime'];
for (const slot of expected) {
assert.ok(slots.includes(slot), `sessionHolding is missing slot ${slot}`);
}
});
});