fix(settings): persist every SMS template variable on save

Mongoose Mixed updates were dropping extra invoiceCreated variables after a successful save.
This commit is contained in:
2026-08-15 22:55:54 +03:30
parent d3190a8729
commit 366cd41bb7
5 changed files with 176 additions and 93 deletions
+2 -1
View File
@@ -17,7 +17,8 @@ const settingSchema = new mongoose.Schema({
default: {}
}
}, {
timestamps: true
timestamps: true,
minimize: false
});
const Setting = mongoose.model('Setting', settingSchema);
+22 -59
View File
@@ -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))
};
};
+89 -32
View File
@@ -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
};
+61
View File
@@ -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']);
});
});
+2 -1
View File
@@ -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",