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:
@@ -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) => {
|
||||||
key: def.key,
|
const entry = normalizeStoredEntry(storedMap[def.key]);
|
||||||
label: def.label,
|
const variables = resolveVariableMap(def, entry.variables);
|
||||||
variables: def.variables,
|
return {
|
||||||
templateId: resolveTemplateId(storedMap, def)
|
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 () => {
|
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]);
|
|
||||||
if (sanitized === null) {
|
let templateId = storedEntry.templateId || envFallbackFor(def);
|
||||||
throw new AppError('VALIDATION_FAILED', { field: def.key }, `Invalid SMS template ID for ${def.key}`);
|
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]) {
|
if (incomingEntry.variables && typeof incomingEntry.variables === 'object') {
|
||||||
nextMap[def.key] = String(storedMap[def.key]).trim();
|
const incomingVars = Array.isArray(incomingEntry.variables)
|
||||||
} else {
|
? Object.fromEntries(incomingEntry.variables.map((item) => [item.slot, item.name]))
|
||||||
nextMap[def.key] = envFallbackFor(def);
|
: 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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
])
|
}))
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user