From d2d4ee719b08dd92d0ff2ab1953e88c5d10794f9 Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Sat, 15 Aug 2026 21:58:10 +0330 Subject: [PATCH] feat(settings): support dynamic number of SMS template variables --- components/settings/settingService.js | 64 ++++++++----- components/settings/smsTemplates.js | 127 ++++++++++++++++++-------- utils/senders/smsMessages.js | 43 +++++++-- 3 files changed, 168 insertions(+), 66 deletions(-) diff --git a/components/settings/settingService.js b/components/settings/settingService.js index 426e146..516394d 100644 --- a/components/settings/settingService.js +++ b/components/settings/settingService.js @@ -9,7 +9,7 @@ const { sanitizeVariableName, emptyTemplateEntry, normalizeStoredEntry, - resolveVariableMap + resolveVariablesList } = require('./smsTemplates'); const emptyTemplateMap = () => { @@ -36,18 +36,25 @@ const resolveTemplateId = (entry, def) => { const toPublicTemplates = (storedMap) => { return SMS_TEMPLATE_DEFS.map((def) => { - const entry = normalizeStoredEntry(storedMap[def.key]); - const variables = resolveVariableMap(def, entry.variables); + const entry = normalizeStoredEntry(storedMap[def.key], def); + const variables = resolveVariablesList(def, entry.variables); return { key: def.key, label: def.label, templateId: resolveTemplateId(entry, def), - variables: (def.slots || []).map((slot) => ({ + availableSlots: (def.slots || []).map((slot) => ({ slot: slot.key, label: slot.label, - defaultName: slot.defaultName, - name: variables[slot.key] - })) + defaultName: slot.defaultName + })), + variables: variables.map((v) => { + const slotDef = (def.slots || []).find((s) => s.key === v.slot); + return { + slot: v.slot, + label: slotDef?.label || v.slot, + name: v.name + }; + }) }; }); }; @@ -63,13 +70,13 @@ const getSettings = async () => { const getSmsTemplate = async (key) => { const def = SMS_TEMPLATE_DEFS.find((item) => item.key === key); if (!def) { - return { templateId: '', variables: {} }; + return { templateId: '', variables: [] }; } const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); - const entry = normalizeStoredEntry(readStoredMap(doc)[key]); + const entry = normalizeStoredEntry(readStoredMap(doc)[key], def); return { templateId: resolveTemplateId(entry, def), - variables: resolveVariableMap(def, entry.variables) + variables: resolveVariablesList(def, entry.variables) }; }; @@ -101,12 +108,12 @@ const saveSettings = async (body = {}) => { const nextMap = emptyTemplateMap(); for (const def of SMS_TEMPLATE_DEFS) { - const storedEntry = normalizeStoredEntry(storedMap[def.key]); + const storedEntry = normalizeStoredEntry(storedMap[def.key], def); const hasIncoming = Object.prototype.hasOwnProperty.call(incomingMap, def.key); const incomingEntry = hasIncoming ? parseIncomingEntry(incomingMap[def.key]) : null; let templateId = storedEntry.templateId || envFallbackFor(def); - let variables = { ...storedEntry.variables }; + let variables = storedEntry.variables; if (incomingEntry) { if (incomingEntry.templateId !== undefined) { @@ -117,23 +124,32 @@ const saveSettings = async (body = {}) => { 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; + if (incomingEntry.variables !== undefined) { + let rawList = []; + if (Array.isArray(incomingEntry.variables)) { + rawList = incomingEntry.variables; + } else if (incomingEntry.variables && typeof incomingEntry.variables === 'object') { + rawList = Object.entries(incomingEntry.variables).map(([slot, name]) => ({ slot, name })); + } - (def.slots || []).forEach((slot) => { - if (!Object.prototype.hasOwnProperty.call(incomingVars, slot.key)) return; - const sanitized = sanitizeVariableName(incomingVars[slot.key]); + const validVars = []; + for (const item of rawList) { + if (!item || typeof item !== 'object') continue; + const slot = String(item.slot || '').trim(); + if (!slot) continue; + const rawName = String(item.name || '').trim(); + if (!rawName) continue; + const sanitized = sanitizeVariableName(rawName); if (sanitized === null) { throw new AppError( 'VALIDATION_FAILED', - { field: `${def.key}.${slot.key}` }, - `Invalid SMS variable name for ${def.key}.${slot.key}` + { field: `${def.key}.${slot}` }, + `Invalid SMS variable name "${rawName}" for ${def.key}` ); } - variables[slot.key] = sanitized || slot.defaultName; - }); + validVars.push({ slot, name: sanitized }); + } + variables = validVars; } } else if (!storedEntry.templateId) { templateId = envFallbackFor(def); @@ -141,7 +157,7 @@ const saveSettings = async (body = {}) => { nextMap[def.key] = { templateId, - variables: resolveVariableMap(def, variables) + variables: resolveVariablesList(def, variables) }; } diff --git a/components/settings/smsTemplates.js b/components/settings/smsTemplates.js index 619586d..4243ced 100644 --- a/components/settings/smsTemplates.js +++ b/components/settings/smsTemplates.js @@ -7,7 +7,9 @@ const SMS_TEMPLATE_DEFS = [ envKey: 'SMS_TEMPLATE_ACCOUNT_CREATED', slots: [ { key: 'username', label: 'نام کاربری', defaultName: 'user' }, - { key: 'password', label: 'رمز عبور', defaultName: 'password' } + { key: 'password', label: 'رمز عبور', defaultName: 'password' }, + { key: 'fullName', label: 'نام کاربر', defaultName: 'name' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } ] }, { @@ -15,7 +17,10 @@ const SMS_TEMPLATE_DEFS = [ label: 'ثبت‌نام در کلاس', envKey: 'SMS_TEMPLATE_CLASS_REGISTERED', slots: [ - { key: 'className', label: 'نام کلاس', defaultName: 'className' } + { key: 'className', label: 'نام کلاس', defaultName: 'className' }, + { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, + { key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } ] }, { @@ -24,8 +29,11 @@ const SMS_TEMPLATE_DEFS = [ envKey: 'SMS_TEMPLATE_CLASS_REMINDER', slots: [ { key: 'className', label: 'نام کلاس', defaultName: 'className' }, + { key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, { key: 'time', label: 'ساعت', defaultName: 'time' }, - { key: 'place', label: 'مکان', defaultName: 'place' } + { key: 'place', label: 'مکان', defaultName: 'place' }, + { key: 'fullName', label: 'نام کاربر', defaultName: 'fullName' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } ] }, { @@ -35,7 +43,8 @@ const SMS_TEMPLATE_DEFS = [ slots: [ { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, { key: 'amount', label: 'مبلغ', defaultName: 'PAYMENT_PRICE' }, - { key: 'course', label: 'نام دوره', defaultName: 'COURSE' } + { key: 'course', label: 'نام دوره', defaultName: 'COURSE' }, + { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' } ] } ]; @@ -63,37 +72,53 @@ const sanitizeVariableName = (value) => { return name; }; -const defaultVariableMap = (def) => { - const map = {}; - (def.slots || []).forEach((slot) => { - map[slot.key] = slot.defaultName; - }); - return map; +const defaultVariablesList = (def) => { + return (def?.slots || []).map((slot) => ({ + slot: slot.key, + name: slot.defaultName + })); }; const emptyTemplateEntry = (def) => ({ templateId: '', - variables: defaultVariableMap(def) + variables: defaultVariablesList(def) }); -const normalizeStoredEntry = (raw) => { +const normalizeStoredEntry = (raw, def) => { if (raw == null || raw === '') { - return { templateId: '', variables: {} }; + return { + templateId: '', + variables: def ? defaultVariablesList(def) : [] + }; } if (typeof raw === 'string' || typeof raw === 'number') { - return { templateId: String(raw), variables: {} }; + return { + templateId: String(raw), + variables: def ? defaultVariablesList(def) : [] + }; } if (typeof raw !== 'object') { - return { templateId: '', variables: {} }; + return { + templateId: '', + variables: def ? defaultVariablesList(def) : [] + }; } - 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; - }); + let variables = []; + if (Array.isArray(raw.variables)) { + variables = raw.variables + .filter((item) => item && typeof item === 'object' && item.slot) + .map((item) => ({ + slot: String(item.slot).trim(), + name: String(item.name || '').trim() + })); + } else if (raw.variables && typeof raw.variables === 'object') { + variables = Object.entries(raw.variables).map(([slot, name]) => ({ + slot: String(slot).trim(), + name: String(name || '').trim() + })); + } else if (def) { + variables = defaultVariablesList(def); } return { @@ -102,21 +127,51 @@ const normalizeStoredEntry = (raw) => { }; }; -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 resolveVariablesList = (def, storedVariables) => { + if (!storedVariables) { + return defaultVariablesList(def); + } + + let list = []; + if (Array.isArray(storedVariables)) { + list = storedVariables; + } else if (typeof storedVariables === 'object') { + list = Object.entries(storedVariables).map(([slot, name]) => ({ slot, name })); + } + + return list + .map((item) => { + const slot = String(item.slot || '').trim(); + if (!slot) return null; + const slotDef = (def?.slots || []).find((s) => s.key === slot); + const rawName = item.name !== undefined ? item.name : slotDef?.defaultName || ''; + const sanitized = sanitizeVariableName(rawName); + return { + slot, + name: sanitized || slotDef?.defaultName || slot + }; + }) + .filter(Boolean); }; -const buildSmsParameters = (variableMap, valuesBySlot) => { - return Object.entries(valuesBySlot) - .map(([slot, value]) => { - const name = String(variableMap?.[slot] || '').trim(); +const buildSmsParameters = (variables, valuesBySlot = {}) => { + let list = []; + if (Array.isArray(variables)) { + list = variables; + } else if (variables && typeof variables === 'object') { + list = Object.entries(variables).map(([slot, name]) => ({ slot, name })); + } + + return list + .map((item) => { + const name = sanitizeVariableName(item?.name); if (!name) return null; - return { name, value: String(value ?? '') }; + const slot = item?.slot; + const value = slot ? valuesBySlot?.[slot] : ''; + return { + name, + value: String(value ?? '') + }; }) .filter(Boolean); }; @@ -127,9 +182,9 @@ module.exports = { envFallbackFor, sanitizeTemplateId, sanitizeVariableName, - defaultVariableMap, + defaultVariablesList, emptyTemplateEntry, normalizeStoredEntry, - resolveVariableMap, + resolveVariablesList, buildSmsParameters }; diff --git a/utils/senders/smsMessages.js b/utils/senders/smsMessages.js index 23f66d3..f9924eb 100644 --- a/utils/senders/smsMessages.js +++ b/utils/senders/smsMessages.js @@ -15,6 +15,11 @@ const resolveUserIdByPhone = async (phoneNumber) => { const sendAccountCreatedSms = async (receiver, username, password, userId = null) => { const resolvedUserId = userId || await resolveUserIdByPhone(receiver); + let fullName = ''; + if (resolvedUserId) { + const user = await User.findById(resolvedUserId).select('name').lean(); + if (user?.name) fullName = user.name; + } const { templateId, variables } = await getSmsTemplate('accountCreated'); return recordAndSend({ userId: resolvedUserId, @@ -24,13 +29,22 @@ const sendAccountCreatedSms = async (receiver, username, password, userId = null relatedEvent: 'user.created', sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, { username, - password + password, + fullName: fullName || username, + name: fullName || username, + phoneNumber: receiver, + mobile: receiver })) }); }; -const sendClassRegisteredSms = async (receiver, className, userId = null) => { +const sendClassRegisteredSms = async (receiver, className, userId = null, extraContext = {}) => { const resolvedUserId = userId || await resolveUserIdByPhone(receiver); + let fullName = extraContext.fullName || ''; + if (!fullName && resolvedUserId) { + const user = await User.findById(resolvedUserId).select('name').lean(); + if (user?.name) fullName = user.name; + } const { templateId, variables } = await getSmsTemplate('classRegistered'); return recordAndSend({ userId: resolvedUserId, @@ -39,13 +53,23 @@ const sendClassRegisteredSms = async (receiver, className, userId = null) => { body: `ثبت‌نام شما در کلاس «${className}» انجام شد.`, relatedEvent: 'user.enrolled', sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, { - className + className, + courseName: extraContext.courseName || className, + fullName: fullName || 'کارآموز', + phoneNumber: receiver, + mobile: receiver, + ...extraContext })) }); }; -const sendClassReminderSms = async (receiver, className, time, place = '', userId = null) => { +const sendClassReminderSms = async (receiver, className, time, place = '', userId = null, extraContext = {}) => { const resolvedUserId = userId || await resolveUserIdByPhone(receiver); + let fullName = extraContext.fullName || ''; + if (!fullName && resolvedUserId) { + const user = await User.findById(resolvedUserId).select('name').lean(); + if (user?.name) fullName = user.name; + } const { templateId, variables } = await getSmsTemplate('classReminder'); return recordAndSend({ userId: resolvedUserId, @@ -55,8 +79,13 @@ const sendClassReminderSms = async (receiver, className, time, place = '', userI relatedEvent: 'session.reminder', sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, { className, + courseName: extraContext.courseName || className, time, - place: place || '-' + place: place || '-', + fullName: fullName || 'کارآموز', + phoneNumber: receiver, + mobile: receiver, + ...extraContext })) }); }; @@ -90,7 +119,9 @@ const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => { sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, { fullName: fullNameLabel, amount: amountLabel, - course: courseLabel + course: courseLabel, + phoneNumber: receiver, + mobile: receiver })) }); };