diff --git a/components/settings/settingModel.js b/components/settings/settingModel.js index dd0aab1..04aa694 100644 --- a/components/settings/settingModel.js +++ b/components/settings/settingModel.js @@ -17,7 +17,8 @@ const settingSchema = new mongoose.Schema({ default: {} } }, { - timestamps: true + timestamps: true, + minimize: false }); const Setting = mongoose.model('Setting', settingSchema); diff --git a/components/settings/settingService.js b/components/settings/settingService.js index d745620..7152718 100644 --- a/components/settings/settingService.js +++ b/components/settings/settingService.js @@ -5,11 +5,10 @@ const { Setting, SETTINGS_KEY } = require('./settingModel'); const { SMS_TEMPLATE_DEFS, envFallbackFor, - sanitizeTemplateId, - sanitizeVariableName, emptyTemplateEntry, normalizeStoredEntry, - resolveVariablesList + resolveVariablesList, + mergeTemplateEntry } = require('./smsTemplates'); const emptyTemplateMap = () => { @@ -103,75 +102,39 @@ const saveSettings = async (body = {}) => { ? Object.fromEntries(incoming.map((item) => [item.key, item])) : incoming; - const existing = await Setting.findOne({ key: SETTINGS_KEY }).lean(); - const storedMap = existing ? readStoredMap(existing) : emptyTemplateMap(); + const existing = await Setting.findOne({ key: SETTINGS_KEY }); + const storedMap = existing ? readStoredMap(existing.toObject ? existing.toObject() : existing) : emptyTemplateMap(); const nextMap = {}; for (const def of SMS_TEMPLATE_DEFS) { - 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; - - if (incomingEntry) { - if (incomingEntry.templateId !== undefined) { - const sanitized = sanitizeTemplateId(incomingEntry.templateId); - if (sanitized === null) { - throw new AppError('VALIDATION_FAILED', { field: def.key }, `شناسه قالب پیامک برای ${def.label} نامعتبر است`); - } - templateId = sanitized; + try { + nextMap[def.key] = mergeTemplateEntry(def, storedMap[def.key], incomingEntry); + } catch (err) { + if (err.code === 'INVALID_TEMPLATE_ID') { + throw new AppError('VALIDATION_FAILED', { field: def.key }, `شناسه قالب پیامک برای ${def.label} نامعتبر است`); } - - 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 })); - } - - 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(); - const sanitized = sanitizeVariableName(rawName); - if (sanitized === null) { - throw new AppError( - 'VALIDATION_FAILED', - { field: `${def.key}.${slot}` }, - `نام متغیر «${rawName}» برای ${def.label} نامعتبر است` - ); - } - const slotDef = (def?.slots || []).find((s) => s.key === slot); - validVars.push({ - slot, - name: sanitized || slotDef?.defaultName || slot - }); - } - variables = validVars; + if (err.code === 'INVALID_VARIABLE_NAME') { + throw new AppError( + 'VALIDATION_FAILED', + { field: err.field || def.key }, + `نام متغیر «${err.rawName}» برای ${def.label} نامعتبر است` + ); } - } else if (!storedEntry.templateId) { - templateId = envFallbackFor(def); + throw err; } - - nextMap[def.key] = { - templateId, - variables: resolveVariablesList(def, variables) - }; } - const doc = await Setting.findOneAndUpdate( - { key: SETTINGS_KEY }, - { $set: { smsTemplates: nextMap } }, - { upsert: true, new: true, setDefaultsOnInsert: true } - ).lean(); + const doc = existing || new Setting({ key: SETTINGS_KEY }); + doc.set('smsTemplates', JSON.parse(JSON.stringify(nextMap))); + doc.markModified('smsTemplates'); + await doc.save(); + const saved = await Setting.findOne({ key: SETTINGS_KEY }).lean(); return { - smsTemplates: toPublicTemplates(readStoredMap(doc)) + smsTemplates: toPublicTemplates(readStoredMap(saved)) }; }; diff --git a/components/settings/smsTemplates.js b/components/settings/smsTemplates.js index a42f4f1..a40fc81 100644 --- a/components/settings/smsTemplates.js +++ b/components/settings/smsTemplates.js @@ -86,6 +86,42 @@ const emptyTemplateEntry = (def) => ({ variables: defaultVariablesList(def) }); +const toVariableItem = (item, fallbackSlot = '') => { + if (item == null) return null; + if (typeof item === 'object') { + const slot = String(item.slot || fallbackSlot || '').trim(); + if (!slot) return null; + return { + slot, + name: String(item.name || '').trim() + }; + } + const slot = String(fallbackSlot || '').trim(); + if (!slot) return null; + return { + slot, + name: String(item).trim() + }; +}; + +const normalizeVariablesInput = (rawVariables, def) => { + if (rawVariables == null) { + return def ? defaultVariablesList(def) : []; + } + + if (Array.isArray(rawVariables)) { + return rawVariables.map((item) => toVariableItem(item)).filter(Boolean); + } + + if (typeof rawVariables === 'object') { + return Object.entries(rawVariables) + .map(([key, value]) => toVariableItem(value, key)) + .filter(Boolean); + } + + return def ? defaultVariablesList(def) : []; +}; + const normalizeStoredEntry = (raw, def) => { if (raw == null || raw === '') { return { @@ -106,22 +142,9 @@ const normalizeStoredEntry = (raw, def) => { }; } - 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); - } + const variables = Object.prototype.hasOwnProperty.call(raw, 'variables') + ? normalizeVariablesInput(raw.variables, def) + : (def ? defaultVariablesList(def) : []); return { templateId: raw.templateId != null ? String(raw.templateId) : '', @@ -129,19 +152,58 @@ const normalizeStoredEntry = (raw, def) => { }; }; +const parseIncomingVariables = (rawVariables, def) => { + if (rawVariables === undefined) return undefined; + return normalizeVariablesInput(rawVariables, def); +}; + +const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => { + const storedEntry = normalizeStoredEntry(storedRaw, def); + let templateId = storedEntry.templateId || envFallbackFor(def); + let variables = storedEntry.variables; + + if (incomingRaw) { + if (incomingRaw.templateId !== undefined) { + const sanitized = sanitizeTemplateId(incomingRaw.templateId); + if (sanitized === null) { + const error = new Error(`Invalid SMS template ID for ${def?.key || 'template'}`); + error.code = 'INVALID_TEMPLATE_ID'; + error.field = def?.key; + throw error; + } + templateId = sanitized; + } + + if (incomingRaw.variables !== undefined) { + const parsed = parseIncomingVariables(incomingRaw.variables, def) || []; + for (const item of parsed) { + const sanitized = sanitizeVariableName(item.name); + if (item.name && sanitized === null) { + const error = new Error(`Invalid SMS variable name "${item.name}"`); + error.code = 'INVALID_VARIABLE_NAME'; + error.field = `${def?.key}.${item.slot}`; + error.rawName = item.name; + throw error; + } + } + variables = parsed; + } + } else if (!storedEntry.templateId) { + templateId = envFallbackFor(def); + } + + return { + templateId, + variables: resolveVariablesList(def, variables) + }; +}; + 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 + return normalizeVariablesInput(storedVariables, def) .map((item) => { const slot = String(item.slot || '').trim(); if (!slot) return null; @@ -159,14 +221,7 @@ const resolveVariablesList = (def, storedVariables) => { }; 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 + return normalizeVariablesInput(variables, null) .map((item) => { const name = sanitizeVariableName(item?.name); if (!name) return null; @@ -189,6 +244,8 @@ module.exports = { defaultVariablesList, emptyTemplateEntry, normalizeStoredEntry, + parseIncomingVariables, + mergeTemplateEntry, resolveVariablesList, buildSmsParameters }; diff --git a/components/settings/smsTemplates.test.js b/components/settings/smsTemplates.test.js new file mode 100644 index 0000000..880ee9d --- /dev/null +++ b/components/settings/smsTemplates.test.js @@ -0,0 +1,61 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + SMS_TEMPLATE_DEFS, + normalizeStoredEntry, + resolveVariablesList, + parseIncomingVariables, + mergeTemplateEntry +} = require('./smsTemplates'); + +const invoiceDef = SMS_TEMPLATE_DEFS.find((def) => def.key === 'invoiceCreated'); + +const threeInvoiceVars = [ + { slot: 'amount', name: 'PAYMENT_PRICE' }, + { slot: 'fullName', name: 'FULLNAME' }, + { slot: 'course', name: 'COURSE' } +]; + +describe('SMS template variables', () => { + it('keeps all invoiceCreated variables when stored as an array', () => { + const stored = normalizeStoredEntry({ + templateId: '111', + variables: threeInvoiceVars + }, invoiceDef); + const resolved = resolveVariablesList(invoiceDef, stored.variables); + + assert.equal(resolved.length, 3); + assert.deepEqual(resolved.map((item) => item.slot), ['amount', 'fullName', 'course']); + assert.deepEqual(resolved.map((item) => item.name), ['PAYMENT_PRICE', 'FULLNAME', 'COURSE']); + }); + + it('keeps all invoiceCreated variables when an array was stored as a numeric object', () => { + const stored = normalizeStoredEntry({ + templateId: '111', + variables: { + 0: { slot: 'amount', name: 'PAYMENT_PRICE' }, + 1: { slot: 'fullName', name: 'FULLNAME' }, + 2: { slot: 'course', name: 'COURSE' } + } + }, invoiceDef); + const resolved = resolveVariablesList(invoiceDef, stored.variables); + + assert.equal(resolved.length, 3); + assert.deepEqual(resolved.map((item) => item.slot), ['amount', 'fullName', 'course']); + }); + + it('replaces a legacy single-slot map with every incoming invoiceCreated variable', () => { + const incoming = parseIncomingVariables(threeInvoiceVars, invoiceDef); + const merged = mergeTemplateEntry( + invoiceDef, + { templateId: '111', variables: { amount: 'PAYMENT_PRICE' } }, + { templateId: '111', variables: incoming } + ); + + assert.equal(merged.variables.length, 3); + assert.deepEqual(merged.variables.map((item) => item.slot), ['amount', 'fullName', 'course']); + assert.deepEqual(merged.variables.map((item) => item.name), ['PAYMENT_PRICE', 'FULLNAME', 'COURSE']); + }); +}); diff --git a/package.json b/package.json index f56dcb7..277c503 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "start": "node app.js", "dev": "nodemon app.js", - "seed": "node seed.js" + "seed": "node seed.js", + "test": "node --test components/settings/smsTemplates.test.js" }, "keywords": [ "express",