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 {
SMS_TEMPLATE_DEFS,
envFallbackFor,
sanitizeTemplateId
sanitizeTemplateId,
sanitizeVariableName,
emptyTemplateEntry,
normalizeStoredEntry,
resolveVariableMap
} = require('./smsTemplates');
const emptyTemplateMap = () => {
const map = {};
SMS_TEMPLATE_DEFS.forEach((def) => {
map[def.key] = '';
map[def.key] = emptyTemplateEntry(def);
});
return map;
};
@@ -24,19 +28,28 @@ const readStoredMap = (doc) => {
return { ...stored };
};
const resolveTemplateId = (storedMap, def) => {
const fromDb = String(storedMap[def.key] || '').trim();
const resolveTemplateId = (entry, def) => {
const fromDb = String(entry?.templateId || '').trim();
if (fromDb) return fromDb;
return envFallbackFor(def);
};
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,
label: def.label,
variables: def.variables,
templateId: resolveTemplateId(storedMap, def)
}));
templateId: resolveTemplateId(entry, def),
variables: (def.slots || []).map((slot) => ({
slot: slot.key,
label: slot.label,
defaultName: slot.defaultName,
name: variables[slot.key]
}))
};
});
};
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);
if (!def) return '';
if (!def) {
return { templateId: '', variables: {} };
}
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 incoming = body.smsTemplates || {};
const incomingMap = Array.isArray(incoming)
? Object.fromEntries(incoming.map((item) => [item.key, item.templateId]))
? Object.fromEntries(incoming.map((item) => [item.key, item]))
: incoming;
const existing = await Setting.findOne({ key: SETTINGS_KEY });
@@ -65,18 +101,48 @@ const saveSettings = async (body = {}) => {
const nextMap = emptyTemplateMap();
for (const def of SMS_TEMPLATE_DEFS) {
const storedEntry = normalizeStoredEntry(storedMap[def.key]);
const hasIncoming = Object.prototype.hasOwnProperty.call(incomingMap, def.key);
if (hasIncoming) {
const sanitized = sanitizeTemplateId(incomingMap[def.key]);
const incomingEntry = hasIncoming ? parseIncomingEntry(incomingMap[def.key]) : null;
let templateId = storedEntry.templateId || envFallbackFor(def);
let variables = { ...storedEntry.variables };
if (incomingEntry) {
if (incomingEntry.templateId !== undefined) {
const sanitized = sanitizeTemplateId(incomingEntry.templateId);
if (sanitized === null) {
throw new AppError('VALIDATION_FAILED', { field: def.key }, `Invalid SMS template ID for ${def.key}`);
}
nextMap[def.key] = sanitized;
} else if (storedMap[def.key]) {
nextMap[def.key] = String(storedMap[def.key]).trim();
} else {
nextMap[def.key] = envFallbackFor(def);
templateId = sanitized;
}
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(
@@ -93,5 +159,6 @@ const saveSettings = async (body = {}) => {
module.exports = {
getSettings,
saveSettings,
getSmsTemplate,
getSmsTemplateId
};
+92 -9
View File
@@ -4,26 +4,37 @@ const SMS_TEMPLATE_DEFS = [
{
key: 'accountCreated',
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',
label: 'ثبت‌نام در کلاس',
variables: ['className'],
envKey: 'SMS_TEMPLATE_CLASS_REGISTERED'
envKey: 'SMS_TEMPLATE_CLASS_REGISTERED',
slots: [
{ key: 'className', label: 'نام کلاس', defaultName: 'className' }
]
},
{
key: 'classReminder',
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',
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;
};
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 = {
SMS_TEMPLATE_DEFS,
SMS_TEMPLATE_KEYS,
envFallbackFor,
sanitizeTemplateId
sanitizeTemplateId,
sanitizeVariableName,
defaultVariableMap,
emptyTemplateEntry,
normalizeStoredEntry,
resolveVariableMap,
buildSmsParameters
};
+21 -20
View File
@@ -3,7 +3,8 @@
const { sendSingleSms } = require('./sms.base');
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 resolveUserIdByPhone = async (phoneNumber) => {
@@ -14,55 +15,55 @@ const resolveUserIdByPhone = async (phoneNumber) => {
const sendAccountCreatedSms = async (receiver, username, password, userId = null) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const templateId = await getSmsTemplateId('accountCreated');
const { templateId, variables } = await getSmsTemplate('accountCreated');
return recordAndSend({
userId: resolvedUserId,
channel: 'sms',
subject: 'ایجاد حساب کاربری',
body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`,
relatedEvent: 'user.created',
sendFn: () => sendSingleSms(receiver, templateId, [
{ name: 'user', value: String(username) },
{ name: 'password', value: String(password) }
])
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
username,
password
}))
});
};
const sendClassRegisteredSms = async (receiver, className, userId = null) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const templateId = await getSmsTemplateId('classRegistered');
const { templateId, variables } = await getSmsTemplate('classRegistered');
return recordAndSend({
userId: resolvedUserId,
channel: 'sms',
subject: 'ثبت‌نام در کلاس',
body: `ثبت‌نام شما در کلاس «${className}» انجام شد.`,
relatedEvent: 'user.enrolled',
sendFn: () => sendSingleSms(receiver, templateId, [
{ name: 'className', value: String(className) }
])
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
className
}))
});
};
const sendClassReminderSms = async (receiver, className, time, place = '', userId = null) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const templateId = await getSmsTemplateId('classReminder');
const { templateId, variables } = await getSmsTemplate('classReminder');
return recordAndSend({
userId: resolvedUserId,
channel: 'sms',
subject: 'یادآوری کلاس',
body: `یادآوری کلاس «${className}» ساعت ${time} — مکان: ${place || '-'}`,
relatedEvent: 'session.reminder',
sendFn: () => sendSingleSms(receiver, templateId, [
{ name: 'className', value: String(className) },
{ name: 'time', value: String(time) },
{ name: 'place', value: String(place || '-') }
])
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
className,
time,
place: place || '-'
}))
});
};
const sendInvoiceCreatedSms = async (receiver, amount, userId = null) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const templateId = await getSmsTemplateId('invoiceCreated');
const { templateId, variables } = await getSmsTemplate('invoiceCreated');
const amountLabel = String(amount);
return recordAndSend({
userId: resolvedUserId,
@@ -70,9 +71,9 @@ const sendInvoiceCreatedSms = async (receiver, amount, userId = null) => {
subject: 'ایجاد صورتحساب',
body: `صورتحساب جدید به مبلغ ${amountLabel} تومان صادر شد.`,
relatedEvent: 'payment.created',
sendFn: () => sendSingleSms(receiver, templateId, [
{ name: 'amount', value: amountLabel }
])
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
amount: amountLabel
}))
});
};