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:
@@ -17,7 +17,8 @@ const settingSchema = new mongoose.Schema({
|
||||
default: {}
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
timestamps: true,
|
||||
minimize: false
|
||||
});
|
||||
|
||||
const Setting = mongoose.model('Setting', settingSchema);
|
||||
|
||||
@@ -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) {
|
||||
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} نامعتبر است`);
|
||||
}
|
||||
templateId = sanitized;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (err.code === 'INVALID_VARIABLE_NAME') {
|
||||
throw new AppError(
|
||||
'VALIDATION_FAILED',
|
||||
{ field: `${def.key}.${slot}` },
|
||||
`نام متغیر «${rawName}» برای ${def.label} نامعتبر است`
|
||||
{ field: err.field || def.key },
|
||||
`نام متغیر «${err.rawName}» برای ${def.label} نامعتبر است`
|
||||
);
|
||||
}
|
||||
const slotDef = (def?.slots || []).find((s) => s.key === slot);
|
||||
validVars.push({
|
||||
slot,
|
||||
name: sanitized || slotDef?.defaultName || slot
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
variables = validVars;
|
||||
}
|
||||
} else if (!storedEntry.templateId) {
|
||||
templateId = envFallbackFor(def);
|
||||
}
|
||||
|
||||
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))
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user