Allow enabling/disabling individual SMS template types from dashboard with sync to notification settings
This commit is contained in:
@@ -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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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',
|
||||||
|
|||||||
Reference in New Issue
Block a user