From 7ebf9409f0d445bf587e08f059ca34ee18429372 Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Sat, 15 Aug 2026 18:43:24 +0330 Subject: [PATCH] feat: let SuperAdmin rename SMS template variable names Store custom sms.ir parameter names per template slot so outbound messages match the panel placeholders. --- components/settings/settingService.js | 113 ++++++++++++++++++++------ components/settings/smsTemplates.js | 101 +++++++++++++++++++++-- utils/senders/smsMessages.js | 41 +++++----- 3 files changed, 203 insertions(+), 52 deletions(-) diff --git a/components/settings/settingService.js b/components/settings/settingService.js index 2df4fbb..426e146 100644 --- a/components/settings/settingService.js +++ b/components/settings/settingService.js @@ -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) => ({ - key: def.key, - label: def.label, - variables: def.variables, - templateId: resolveTemplateId(storedMap, 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, + 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]); - if (sanitized === null) { - throw new AppError('VALIDATION_FAILED', { field: def.key }, `Invalid SMS template ID for ${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}`); + } + templateId = sanitized; } - nextMap[def.key] = 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( @@ -93,5 +159,6 @@ const saveSettings = async (body = {}) => { module.exports = { getSettings, saveSettings, + getSmsTemplate, getSmsTemplateId }; diff --git a/components/settings/smsTemplates.js b/components/settings/smsTemplates.js index decb43d..45d148d 100644 --- a/components/settings/smsTemplates.js +++ b/components/settings/smsTemplates.js @@ -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 }; diff --git a/utils/senders/smsMessages.js b/utils/senders/smsMessages.js index d652db8..5e280ff 100644 --- a/utils/senders/smsMessages.js +++ b/utils/senders/smsMessages.js @@ -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 + })) }); };