feat: let SuperAdmin rename SMS template variable names

Store custom sms.ir parameter names per template slot so outbound messages match the panel placeholders.
This commit is contained in:
2026-08-15 18:43:24 +03:30
parent 3160f07873
commit 7ebf9409f0
3 changed files with 203 additions and 52 deletions
+86 -19
View File
@@ -5,13 +5,17 @@ const { Setting, SETTINGS_KEY } = require('./settingModel');
const { const {
SMS_TEMPLATE_DEFS, SMS_TEMPLATE_DEFS,
envFallbackFor, envFallbackFor,
sanitizeTemplateId sanitizeTemplateId,
sanitizeVariableName,
emptyTemplateEntry,
normalizeStoredEntry,
resolveVariableMap
} = require('./smsTemplates'); } = require('./smsTemplates');
const emptyTemplateMap = () => { const emptyTemplateMap = () => {
const map = {}; const map = {};
SMS_TEMPLATE_DEFS.forEach((def) => { SMS_TEMPLATE_DEFS.forEach((def) => {
map[def.key] = ''; map[def.key] = emptyTemplateEntry(def);
}); });
return map; return map;
}; };
@@ -24,19 +28,28 @@ const readStoredMap = (doc) => {
return { ...stored }; return { ...stored };
}; };
const resolveTemplateId = (storedMap, def) => { const resolveTemplateId = (entry, def) => {
const fromDb = String(storedMap[def.key] || '').trim(); const fromDb = String(entry?.templateId || '').trim();
if (fromDb) return fromDb; if (fromDb) return fromDb;
return envFallbackFor(def); return envFallbackFor(def);
}; };
const toPublicTemplates = (storedMap) => { const toPublicTemplates = (storedMap) => {
return SMS_TEMPLATE_DEFS.map((def) => ({ return SMS_TEMPLATE_DEFS.map((def) => {
const entry = normalizeStoredEntry(storedMap[def.key]);
const variables = resolveVariableMap(def, entry.variables);
return {
key: def.key, key: def.key,
label: def.label, label: def.label,
variables: def.variables, templateId: resolveTemplateId(entry, def),
templateId: resolveTemplateId(storedMap, def) variables: (def.slots || []).map((slot) => ({
})); slot: slot.key,
label: slot.label,
defaultName: slot.defaultName,
name: variables[slot.key]
}))
};
});
}; };
const getSettings = async () => { const getSettings = async () => {
@@ -47,17 +60,40 @@ const getSettings = async () => {
}; };
}; };
const getSmsTemplateId = 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) return ''; if (!def) {
return { templateId: '', variables: {} };
}
const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
return resolveTemplateId(readStoredMap(doc), def); const entry = normalizeStoredEntry(readStoredMap(doc)[key]);
return {
templateId: resolveTemplateId(entry, def),
variables: resolveVariableMap(def, entry.variables)
};
};
const getSmsTemplateId = async (key) => {
const template = await getSmsTemplate(key);
return template.templateId;
};
const parseIncomingEntry = (raw) => {
if (raw == null) return null;
if (typeof raw === 'string' || typeof raw === 'number') {
return { templateId: raw, variables: undefined };
}
if (typeof raw !== 'object') return null;
return {
templateId: raw.templateId,
variables: raw.variables
};
}; };
const saveSettings = async (body = {}) => { const saveSettings = async (body = {}) => {
const incoming = body.smsTemplates || {}; const incoming = body.smsTemplates || {};
const incomingMap = Array.isArray(incoming) const incomingMap = Array.isArray(incoming)
? Object.fromEntries(incoming.map((item) => [item.key, item.templateId])) ? Object.fromEntries(incoming.map((item) => [item.key, item]))
: incoming; : incoming;
const existing = await Setting.findOne({ key: SETTINGS_KEY }); const existing = await Setting.findOne({ key: SETTINGS_KEY });
@@ -65,18 +101,48 @@ const saveSettings = async (body = {}) => {
const nextMap = emptyTemplateMap(); const nextMap = emptyTemplateMap();
for (const def of SMS_TEMPLATE_DEFS) { for (const def of SMS_TEMPLATE_DEFS) {
const storedEntry = normalizeStoredEntry(storedMap[def.key]);
const hasIncoming = Object.prototype.hasOwnProperty.call(incomingMap, def.key); const hasIncoming = Object.prototype.hasOwnProperty.call(incomingMap, def.key);
if (hasIncoming) { const incomingEntry = hasIncoming ? parseIncomingEntry(incomingMap[def.key]) : null;
const sanitized = sanitizeTemplateId(incomingMap[def.key]);
let templateId = storedEntry.templateId || envFallbackFor(def);
let variables = { ...storedEntry.variables };
if (incomingEntry) {
if (incomingEntry.templateId !== undefined) {
const sanitized = sanitizeTemplateId(incomingEntry.templateId);
if (sanitized === null) { if (sanitized === null) {
throw new AppError('VALIDATION_FAILED', { field: def.key }, `Invalid SMS template ID for ${def.key}`); throw new AppError('VALIDATION_FAILED', { field: def.key }, `Invalid SMS template ID for ${def.key}`);
} }
nextMap[def.key] = sanitized; templateId = sanitized;
} else if (storedMap[def.key]) {
nextMap[def.key] = String(storedMap[def.key]).trim();
} else {
nextMap[def.key] = envFallbackFor(def);
} }
if (incomingEntry.variables && typeof incomingEntry.variables === 'object') {
const incomingVars = Array.isArray(incomingEntry.variables)
? Object.fromEntries(incomingEntry.variables.map((item) => [item.slot, item.name]))
: incomingEntry.variables;
(def.slots || []).forEach((slot) => {
if (!Object.prototype.hasOwnProperty.call(incomingVars, slot.key)) return;
const sanitized = sanitizeVariableName(incomingVars[slot.key]);
if (sanitized === null) {
throw new AppError(
'VALIDATION_FAILED',
{ field: `${def.key}.${slot.key}` },
`Invalid SMS variable name for ${def.key}.${slot.key}`
);
}
variables[slot.key] = sanitized || slot.defaultName;
});
}
} else if (!storedEntry.templateId) {
templateId = envFallbackFor(def);
}
nextMap[def.key] = {
templateId,
variables: resolveVariableMap(def, variables)
};
} }
const doc = await Setting.findOneAndUpdate( const doc = await Setting.findOneAndUpdate(
@@ -93,5 +159,6 @@ const saveSettings = async (body = {}) => {
module.exports = { module.exports = {
getSettings, getSettings,
saveSettings, saveSettings,
getSmsTemplate,
getSmsTemplateId getSmsTemplateId
}; };
+92 -9
View File
@@ -4,26 +4,37 @@ const SMS_TEMPLATE_DEFS = [
{ {
key: 'accountCreated', key: 'accountCreated',
label: 'ایجاد حساب کاربری', label: 'ایجاد حساب کاربری',
variables: ['user', 'password'], envKey: 'SMS_TEMPLATE_ACCOUNT_CREATED',
envKey: 'SMS_TEMPLATE_ACCOUNT_CREATED' slots: [
{ key: 'username', label: 'نام کاربری', defaultName: 'user' },
{ key: 'password', label: 'رمز عبور', defaultName: 'password' }
]
}, },
{ {
key: 'classRegistered', key: 'classRegistered',
label: 'ثبت‌نام در کلاس', label: 'ثبت‌نام در کلاس',
variables: ['className'], envKey: 'SMS_TEMPLATE_CLASS_REGISTERED',
envKey: 'SMS_TEMPLATE_CLASS_REGISTERED' slots: [
{ key: 'className', label: 'نام کلاس', defaultName: 'className' }
]
}, },
{ {
key: 'classReminder', key: 'classReminder',
label: 'یادآوری کلاس', label: 'یادآوری کلاس',
variables: ['className', 'time', 'place'], envKey: 'SMS_TEMPLATE_CLASS_REMINDER',
envKey: 'SMS_TEMPLATE_CLASS_REMINDER' slots: [
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'time', label: 'ساعت', defaultName: 'time' },
{ key: 'place', label: 'مکان', defaultName: 'place' }
]
}, },
{ {
key: 'invoiceCreated', key: 'invoiceCreated',
label: 'ایجاد صورتحساب', label: 'ایجاد صورتحساب',
variables: ['amount'], envKey: 'SMS_TEMPLATE_INVOICE_CREATED',
envKey: 'SMS_TEMPLATE_INVOICE_CREATED' slots: [
{ key: 'amount', label: 'مبلغ', defaultName: 'amount' }
]
} }
]; ];
@@ -42,9 +53,81 @@ const sanitizeTemplateId = (value) => {
return digits; return digits;
}; };
const sanitizeVariableName = (value) => {
if (value == null) return '';
const name = String(value).trim().replace(/^#+|#+$/g, '');
if (!name) return '';
if (!/^[A-Za-z][A-Za-z0-9_]{0,49}$/.test(name)) return null;
return name;
};
const defaultVariableMap = (def) => {
const map = {};
(def.slots || []).forEach((slot) => {
map[slot.key] = slot.defaultName;
});
return map;
};
const emptyTemplateEntry = (def) => ({
templateId: '',
variables: defaultVariableMap(def)
});
const normalizeStoredEntry = (raw) => {
if (raw == null || raw === '') {
return { templateId: '', variables: {} };
}
if (typeof raw === 'string' || typeof raw === 'number') {
return { templateId: String(raw), variables: {} };
}
if (typeof raw !== 'object') {
return { templateId: '', variables: {} };
}
let variables = {};
if (raw.variables && typeof raw.variables === 'object' && !Array.isArray(raw.variables)) {
variables = { ...raw.variables };
} else if (Array.isArray(raw.variables)) {
raw.variables.forEach((item) => {
if (item?.slot) variables[item.slot] = item.name;
});
}
return {
templateId: raw.templateId != null ? String(raw.templateId) : '',
variables
};
};
const resolveVariableMap = (def, storedVariables = {}) => {
const map = {};
(def.slots || []).forEach((slot) => {
const sanitized = sanitizeVariableName(storedVariables[slot.key]);
map[slot.key] = sanitized || slot.defaultName;
});
return map;
};
const buildSmsParameters = (variableMap, valuesBySlot) => {
return Object.entries(valuesBySlot)
.map(([slot, value]) => {
const name = String(variableMap?.[slot] || '').trim();
if (!name) return null;
return { name, value: String(value ?? '') };
})
.filter(Boolean);
};
module.exports = { module.exports = {
SMS_TEMPLATE_DEFS, SMS_TEMPLATE_DEFS,
SMS_TEMPLATE_KEYS, SMS_TEMPLATE_KEYS,
envFallbackFor, envFallbackFor,
sanitizeTemplateId sanitizeTemplateId,
sanitizeVariableName,
defaultVariableMap,
emptyTemplateEntry,
normalizeStoredEntry,
resolveVariableMap,
buildSmsParameters
}; };
+21 -20
View File
@@ -3,7 +3,8 @@
const { sendSingleSms } = require('./sms.base'); const { sendSingleSms } = require('./sms.base');
const { recordAndSend } = require('./notificationRecorder'); const { recordAndSend } = require('./notificationRecorder');
const { getSmsTemplateId } = require('../../components/settings/settingService'); const { getSmsTemplate } = require('../../components/settings/settingService');
const { buildSmsParameters } = require('../../components/settings/smsTemplates');
const User = require('../../components/users/userModel'); const User = require('../../components/users/userModel');
const resolveUserIdByPhone = async (phoneNumber) => { const resolveUserIdByPhone = async (phoneNumber) => {
@@ -14,55 +15,55 @@ const resolveUserIdByPhone = async (phoneNumber) => {
const sendAccountCreatedSms = async (receiver, username, password, userId = null) => { const sendAccountCreatedSms = async (receiver, username, password, userId = null) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver); const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const templateId = await getSmsTemplateId('accountCreated'); const { templateId, variables } = await getSmsTemplate('accountCreated');
return recordAndSend({ return recordAndSend({
userId: resolvedUserId, userId: resolvedUserId,
channel: 'sms', channel: 'sms',
subject: 'ایجاد حساب کاربری', subject: 'ایجاد حساب کاربری',
body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`, body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`,
relatedEvent: 'user.created', relatedEvent: 'user.created',
sendFn: () => sendSingleSms(receiver, templateId, [ sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
{ name: 'user', value: String(username) }, username,
{ name: 'password', value: String(password) } password
]) }))
}); });
}; };
const sendClassRegisteredSms = async (receiver, className, userId = null) => { const sendClassRegisteredSms = async (receiver, className, userId = null) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver); const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const templateId = await getSmsTemplateId('classRegistered'); const { templateId, variables } = await getSmsTemplate('classRegistered');
return recordAndSend({ return recordAndSend({
userId: resolvedUserId, userId: resolvedUserId,
channel: 'sms', channel: 'sms',
subject: 'ثبت‌نام در کلاس', subject: 'ثبت‌نام در کلاس',
body: `ثبت‌نام شما در کلاس «${className}» انجام شد.`, body: `ثبت‌نام شما در کلاس «${className}» انجام شد.`,
relatedEvent: 'user.enrolled', relatedEvent: 'user.enrolled',
sendFn: () => sendSingleSms(receiver, templateId, [ sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
{ name: 'className', value: String(className) } className
]) }))
}); });
}; };
const sendClassReminderSms = async (receiver, className, time, place = '', userId = null) => { const sendClassReminderSms = async (receiver, className, time, place = '', userId = null) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver); const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const templateId = await getSmsTemplateId('classReminder'); const { templateId, variables } = await getSmsTemplate('classReminder');
return recordAndSend({ return recordAndSend({
userId: resolvedUserId, userId: resolvedUserId,
channel: 'sms', channel: 'sms',
subject: 'یادآوری کلاس', subject: 'یادآوری کلاس',
body: `یادآوری کلاس «${className}» ساعت ${time} — مکان: ${place || '-'}`, body: `یادآوری کلاس «${className}» ساعت ${time} — مکان: ${place || '-'}`,
relatedEvent: 'session.reminder', relatedEvent: 'session.reminder',
sendFn: () => sendSingleSms(receiver, templateId, [ sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
{ name: 'className', value: String(className) }, className,
{ name: 'time', value: String(time) }, time,
{ name: 'place', value: String(place || '-') } place: place || '-'
]) }))
}); });
}; };
const sendInvoiceCreatedSms = async (receiver, amount, userId = null) => { const sendInvoiceCreatedSms = async (receiver, amount, userId = null) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver); const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const templateId = await getSmsTemplateId('invoiceCreated'); const { templateId, variables } = await getSmsTemplate('invoiceCreated');
const amountLabel = String(amount); const amountLabel = String(amount);
return recordAndSend({ return recordAndSend({
userId: resolvedUserId, userId: resolvedUserId,
@@ -70,9 +71,9 @@ const sendInvoiceCreatedSms = async (receiver, amount, userId = null) => {
subject: 'ایجاد صورتحساب', subject: 'ایجاد صورتحساب',
body: `صورتحساب جدید به مبلغ ${amountLabel} تومان صادر شد.`, body: `صورتحساب جدید به مبلغ ${amountLabel} تومان صادر شد.`,
relatedEvent: 'payment.created', relatedEvent: 'payment.created',
sendFn: () => sendSingleSms(receiver, templateId, [ sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
{ name: 'amount', value: amountLabel } amount: amountLabel
]) }))
}); });
}; };