Allow enabling/disabling individual SMS template types from dashboard with sync to notification settings

This commit is contained in:
2026-08-21 11:04:26 +03:30
parent 091e519280
commit 3c6eb278b0
4 changed files with 104 additions and 7 deletions
+56 -6
View File
@@ -49,13 +49,19 @@ const resolveTemplateId = (entry, def) => {
return envFallbackFor(def); return envFallbackFor(def);
}; };
const toPublicTemplates = (storedMap) => { const toPublicTemplates = (storedMap, notificationMap = {}) => {
return SMS_TEMPLATE_DEFS.map((def) => { return SMS_TEMPLATE_DEFS.map((def) => {
const entry = normalizeStoredEntry(storedMap[def.key], def); const entry = normalizeStoredEntry(storedMap[def.key], def);
const variables = resolveVariablesList(def, entry.variables); const variables = resolveVariablesList(def, entry.variables);
const notifyAction = notificationMap[def.key];
const isEnabled = notifyAction && notifyAction.sms !== undefined
? Boolean(notifyAction.sms)
: entry.enabled;
return { return {
key: def.key, key: def.key,
label: def.label, label: def.label,
enabled: isEnabled,
templateId: resolveTemplateId(entry, def), templateId: resolveTemplateId(entry, def),
availableSlots: (def.slots || []).map((slot) => ({ availableSlots: (def.slots || []).map((slot) => ({
slot: slot.key, slot: slot.key,
@@ -98,8 +104,9 @@ const getSettings = async () => {
} }
const storedMap = readStoredMap(doc); const storedMap = readStoredMap(doc);
const notificationMap = readNotificationStoredMap(doc);
return { return {
smsTemplates: toPublicTemplates(storedMap), smsTemplates: toPublicTemplates(storedMap, notificationMap),
messaging: getPublicMessaging(doc), messaging: getPublicMessaging(doc),
notificationSettings: await getPublicNotificationSettings(doc) notificationSettings: await getPublicNotificationSettings(doc)
}; };
@@ -108,11 +115,18 @@ const getSettings = async () => {
const getSmsTemplate = async (key) => { const getSmsTemplate = async (key) => {
const def = SMS_TEMPLATE_DEFS.find((item) => item.key === key); const def = SMS_TEMPLATE_DEFS.find((item) => item.key === key);
if (!def) { if (!def) {
return { templateId: '', variables: [] }; return { templateId: '', variables: [], enabled: true };
} }
const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
const entry = normalizeStoredEntry(readStoredMap(doc)[key], def); const entry = normalizeStoredEntry(readStoredMap(doc)[key], def);
const notificationStored = readNotificationStoredMap(doc);
const notifyAction = notificationStored[key];
const isEnabled = notifyAction && notifyAction.sms !== undefined
? Boolean(notifyAction.sms)
: entry.enabled;
return { return {
enabled: isEnabled,
templateId: resolveTemplateId(entry, def), templateId: resolveTemplateId(entry, def),
variables: resolveVariablesList(def, entry.variables) variables: resolveVariablesList(def, entry.variables)
}; };
@@ -126,10 +140,11 @@ const getSmsTemplateId = async (key) => {
const parseIncomingEntry = (raw) => { const parseIncomingEntry = (raw) => {
if (raw == null) return null; if (raw == null) return null;
if (typeof raw === 'string' || typeof raw === 'number') { if (typeof raw === 'string' || typeof raw === 'number') {
return { templateId: raw, variables: undefined }; return { templateId: raw, variables: undefined, enabled: undefined };
} }
if (typeof raw !== 'object') return null; if (typeof raw !== 'object') return null;
return { return {
enabled: raw.enabled !== undefined ? Boolean(raw.enabled) : undefined,
templateId: raw.templateId, templateId: raw.templateId,
variables: raw.variables variables: raw.variables
}; };
@@ -172,6 +187,23 @@ const saveSettings = async (body = {}) => {
doc.set('smsTemplates', JSON.parse(JSON.stringify(nextMap))); doc.set('smsTemplates', JSON.parse(JSON.stringify(nextMap)));
doc.markModified('smsTemplates'); doc.markModified('smsTemplates');
// Synchronize smsEnabled to notificationSettings
const storedNotificationMap = existing
? readNotificationStoredMap(existing.toObject ? existing.toObject() : existing)
: emptyNotificationSettingsMap();
const updatedNotificationMap = { ...storedNotificationMap };
for (const def of SMS_TEMPLATE_DEFS) {
if (nextMap[def.key] && nextMap[def.key].enabled !== undefined) {
const currentAction = updatedNotificationMap[def.key] || { sms: true, email: false, bot: false };
updatedNotificationMap[def.key] = {
...currentAction,
sms: Boolean(nextMap[def.key].enabled)
};
}
}
doc.set('notificationSettings', JSON.parse(JSON.stringify(mergeNotificationSettings(updatedNotificationMap))));
doc.markModified('notificationSettings');
} else if (!existing) { } else if (!existing) {
doc.set('smsTemplates', emptyTemplateMap()); doc.set('smsTemplates', emptyTemplateMap());
} }
@@ -191,7 +223,24 @@ const saveSettings = async (body = {}) => {
const nextNotificationMap = mergeNotificationSettings(storedNotificationMap, incomingNotificationSettings); const nextNotificationMap = mergeNotificationSettings(storedNotificationMap, incomingNotificationSettings);
doc.set('notificationSettings', JSON.parse(JSON.stringify(nextNotificationMap))); doc.set('notificationSettings', JSON.parse(JSON.stringify(nextNotificationMap)));
doc.markModified('notificationSettings'); doc.markModified('notificationSettings');
} else if (!existing) {
// Synchronize enabled flag in smsTemplates
const storedSmsMap = existing
? readStoredMap(existing.toObject ? existing.toObject() : existing)
: emptyTemplateMap();
const updatedSmsMap = { ...storedSmsMap };
for (const def of SMS_TEMPLATE_DEFS) {
if (nextNotificationMap[def.key] && nextNotificationMap[def.key].sms !== undefined) {
const currentTemplate = normalizeStoredEntry(updatedSmsMap[def.key], def);
updatedSmsMap[def.key] = {
...currentTemplate,
enabled: Boolean(nextNotificationMap[def.key].sms)
};
}
}
doc.set('smsTemplates', JSON.parse(JSON.stringify(updatedSmsMap)));
doc.markModified('smsTemplates');
} else if (!existing && !hasTemplatePayload) {
doc.set('notificationSettings', emptyNotificationSettingsMap()); doc.set('notificationSettings', emptyNotificationSettingsMap());
} }
@@ -200,8 +249,9 @@ const saveSettings = async (body = {}) => {
invalidateNotificationSettingsCache(); invalidateNotificationSettingsCache();
const saved = await Setting.findOne({ key: SETTINGS_KEY }).lean(); const saved = await Setting.findOne({ key: SETTINGS_KEY }).lean();
const finalNotificationMap = readNotificationStoredMap(saved);
return { return {
smsTemplates: toPublicTemplates(readStoredMap(saved)), smsTemplates: toPublicTemplates(readStoredMap(saved), finalNotificationMap),
messaging: getPublicMessaging(saved), messaging: getPublicMessaging(saved),
notificationSettings: await getPublicNotificationSettings(saved) notificationSettings: await getPublicNotificationSettings(saved)
}; };
+19
View File
@@ -148,6 +148,7 @@ const SMS_TEMPLATE_DEFS = [
key: 'classRequestApproved', key: 'classRequestApproved',
label: 'تأیید درخواست تشکیل کلاس', label: 'تأیید درخواست تشکیل کلاس',
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_APPROVED', envKey: 'SMS_TEMPLATE_CLASS_REQUEST_APPROVED',
defaultEnabled: false,
slots: [ slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
@@ -159,6 +160,7 @@ const SMS_TEMPLATE_DEFS = [
key: 'classRequestRejected', key: 'classRequestRejected',
label: 'رد درخواست ثبت‌نام', label: 'رد درخواست ثبت‌نام',
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_REJECTED', envKey: 'SMS_TEMPLATE_CLASS_REQUEST_REJECTED',
defaultEnabled: false,
slots: [ slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
@@ -171,6 +173,7 @@ const SMS_TEMPLATE_DEFS = [
key: 'pendingRegistration', key: 'pendingRegistration',
label: 'دریافت درخواست ثبت‌نام', label: 'دریافت درخواست ثبت‌نام',
envKey: 'SMS_TEMPLATE_PENDING_REGISTRATION', envKey: 'SMS_TEMPLATE_PENDING_REGISTRATION',
defaultEnabled: false,
slots: [ slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'className', label: 'نام کلاس', defaultName: 'className' }, { key: 'className', label: 'نام کلاس', defaultName: 'className' },
@@ -225,6 +228,7 @@ const defaultVariablesList = (def) => {
}; };
const emptyTemplateEntry = (def) => ({ const emptyTemplateEntry = (def) => ({
enabled: def ? (def.defaultEnabled !== false) : true,
templateId: '', templateId: '',
variables: defaultVariablesList(def) variables: defaultVariablesList(def)
}); });
@@ -266,20 +270,24 @@ const normalizeVariablesInput = (rawVariables, def) => {
}; };
const normalizeStoredEntry = (raw, def) => { const normalizeStoredEntry = (raw, def) => {
const defaultEnabled = def ? (def.defaultEnabled !== false) : true;
if (raw == null || raw === '') { if (raw == null || raw === '') {
return { return {
enabled: defaultEnabled,
templateId: '', templateId: '',
variables: def ? defaultVariablesList(def) : [] variables: def ? defaultVariablesList(def) : []
}; };
} }
if (typeof raw === 'string' || typeof raw === 'number') { if (typeof raw === 'string' || typeof raw === 'number') {
return { return {
enabled: true,
templateId: String(raw), templateId: String(raw),
variables: def ? defaultVariablesList(def) : [] variables: def ? defaultVariablesList(def) : []
}; };
} }
if (typeof raw !== 'object') { if (typeof raw !== 'object') {
return { return {
enabled: defaultEnabled,
templateId: '', templateId: '',
variables: def ? defaultVariablesList(def) : [] variables: def ? defaultVariablesList(def) : []
}; };
@@ -289,7 +297,12 @@ const normalizeStoredEntry = (raw, def) => {
? normalizeVariablesInput(raw.variables, def) ? normalizeVariablesInput(raw.variables, def)
: (def ? defaultVariablesList(def) : []); : (def ? defaultVariablesList(def) : []);
const enabled = raw.enabled !== undefined
? Boolean(raw.enabled)
: defaultEnabled;
return { return {
enabled,
templateId: raw.templateId != null ? String(raw.templateId) : '', templateId: raw.templateId != null ? String(raw.templateId) : '',
variables variables
}; };
@@ -304,8 +317,13 @@ const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => {
const storedEntry = normalizeStoredEntry(storedRaw, def); const storedEntry = normalizeStoredEntry(storedRaw, def);
let templateId = storedEntry.templateId || envFallbackFor(def); let templateId = storedEntry.templateId || envFallbackFor(def);
let variables = storedEntry.variables; let variables = storedEntry.variables;
let enabled = storedEntry.enabled;
if (incomingRaw) { if (incomingRaw) {
if (incomingRaw.enabled !== undefined) {
enabled = Boolean(incomingRaw.enabled);
}
if (incomingRaw.templateId !== undefined) { if (incomingRaw.templateId !== undefined) {
const sanitized = sanitizeTemplateId(incomingRaw.templateId); const sanitized = sanitizeTemplateId(incomingRaw.templateId);
if (sanitized === null) { if (sanitized === null) {
@@ -336,6 +354,7 @@ const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => {
} }
return { return {
enabled,
templateId, templateId,
variables: resolveVariablesList(def, variables) variables: resolveVariablesList(def, variables)
}; };
+23
View File
@@ -92,4 +92,27 @@ describe('SMS template variables', () => {
assert.ok(slots.includes(slot), `sessionHolding is missing slot ${slot}`); assert.ok(slots.includes(slot), `sessionHolding is missing slot ${slot}`);
} }
}); });
it('normalizes and merges enabled property for SMS templates', () => {
const stored = normalizeStoredEntry({
enabled: false,
templateId: '123',
variables: threeInvoiceVars
}, invoiceDef);
assert.equal(stored.enabled, false);
const mergedToTrue = mergeTemplateEntry(
invoiceDef,
stored,
{ enabled: true }
);
assert.equal(mergedToTrue.enabled, true);
const mergedToFalse = mergeTemplateEntry(
invoiceDef,
mergedToTrue,
{ enabled: false }
);
assert.equal(mergedToFalse.enabled, false);
});
}); });
+6 -1
View File
@@ -6,6 +6,7 @@ const { recordAndSend } = require('./notificationRecorder');
const { getSmsTemplate } = require('../../components/settings/settingService'); const { getSmsTemplate } = require('../../components/settings/settingService');
const { buildSmsParameters } = require('../../components/settings/smsTemplates'); const { buildSmsParameters } = require('../../components/settings/smsTemplates');
const User = require('../../components/users/userModel'); const User = require('../../components/users/userModel');
const logger = require('../logger');
const resolveUserIdByPhone = async (phoneNumber) => { const resolveUserIdByPhone = async (phoneNumber) => {
if (!phoneNumber) return null; if (!phoneNumber) return null;
@@ -32,7 +33,11 @@ const sendTemplateSms = async ({
slotValues = {} slotValues = {}
}) => { }) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver); const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const { templateId, variables } = await getSmsTemplate(templateKey); const { templateId, variables, enabled } = await getSmsTemplate(templateKey);
if (enabled === false) {
logger.info(`[SMS] Template ${templateKey} is disabled. Skipping SMS send to ${receiver}.`);
return { success: true, skipped: true, reason: 'template_disabled' };
}
return recordAndSend({ return recordAndSend({
userId: resolvedUserId, userId: resolvedUserId,
channel: 'sms', channel: 'sms',