feat: add messaging toggles, password reset SMS, and payment discounts

Allow SuperAdmin to disable SMS, email, and bot from dashboard settings on top of env flags. Add admin password reset with credentials SMS, plus payment discounts, notes, and payable amount handling.
This commit is contained in:
2026-08-16 06:58:08 +03:30
parent a6c760c3b7
commit 72be01fee3
25 changed files with 601 additions and 43 deletions
+3
View File
@@ -66,6 +66,8 @@ SMTP_PORT=2525
SMTP_USER=your_smtp_user SMTP_USER=your_smtp_user
SMTP_PASS=your_smtp_password SMTP_PASS=your_smtp_password
EMAIL_FROM=no-reply@institution.com EMAIL_FROM=no-reply@institution.com
# Channel kill switches. Dashboard settings must also be enabled for a channel to send.
EMAIL_ENABLED=true
# SMS Provider Configuration (sms.ir) # SMS Provider Configuration (sms.ir)
SMS_ENABLED=false SMS_ENABLED=false
@@ -75,6 +77,7 @@ SMS_SENDER_NUMBER=10001000
# Bale Messenger Bot Token # Bale Messenger Bot Token
BALE_BOT_TOKEN=mock_bale_bot_token BALE_BOT_TOKEN=mock_bale_bot_token
BOT_ENABLED=true
# SuperAdmin bootstrap (created automatically in production) # SuperAdmin bootstrap (created automatically in production)
# Set SUPERADMIN_ENABLED=false to deactivate the bootstrap SuperAdmin and block login # Set SUPERADMIN_ENABLED=false to deactivate the bootstrap SuperAdmin and block login
@@ -16,7 +16,8 @@ const SENSITIVE_KEYS = new Set([
'token', 'token',
'secret', 'secret',
'smtp_pass', 'smtp_pass',
'SMTP_PASS' 'SMTP_PASS',
'generatedCredentials'
]); ]);
const sanitizeValue = (value, depth = 0) => { const sanitizeValue = (value, depth = 0) => {
+13 -3
View File
@@ -3,6 +3,8 @@
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const { getPayableAmount, normalizeDiscount } = require('../../utils/paymentAmount');
const transactionSchema = new mongoose.Schema({ const transactionSchema = new mongoose.Schema({
amount: { type: Number, required: true }, amount: { type: Number, required: true },
method: { method: {
@@ -11,6 +13,7 @@ const transactionSchema = new mongoose.Schema({
default: 'card' default: 'card'
}, },
receiptNumber: { type: String, trim: true }, receiptNumber: { type: String, trim: true },
notes: { type: String, trim: true, maxlength: 5000 },
recordedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, recordedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
date: { type: Date, default: Date.now } date: { type: Date, default: Date.now }
}, { _id: true }); }, { _id: true });
@@ -35,6 +38,11 @@ const paymentSchema = new mongoose.Schema({
required: true, required: true,
min: 0 min: 0
}, },
discount: {
type: Number,
default: 0,
min: 0
},
paidAmount: { paidAmount: {
type: Number, type: Number,
default: 0, default: 0,
@@ -49,14 +57,16 @@ const paymentSchema = new mongoose.Schema({
default: 'pending' default: 'pending'
}, },
transactions: [transactionSchema], transactions: [transactionSchema],
notes: { type: String, trim: true } notes: { type: String, trim: true, maxlength: 5000 }
}, { }, {
timestamps: true timestamps: true
}); });
// Auto-update status based on paid amount // Auto-update status based on paid amount vs payable (amount - discount)
paymentSchema.pre('save', function (next) { paymentSchema.pre('save', function (next) {
if (this.paidAmount >= this.amount) { this.discount = normalizeDiscount(this.discount, this.amount);
const payable = getPayableAmount(this);
if (this.paidAmount >= payable) {
this.status = 'paid'; this.status = 'paid';
} else if (this.paidAmount > 0) { } else if (this.paidAmount > 0) {
this.status = 'partial'; this.status = 'partial';
+11 -1
View File
@@ -13,6 +13,7 @@ const Session = require('../sessions/sessionModel');
const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages'); const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages');
const { buildClassScheduleContext } = require('../../utils/classSchedule'); const { buildClassScheduleContext } = require('../../utils/classSchedule');
const { pickNotifyFlags, omitNotifyFields } = require('../../utils/notifyFlags'); const { pickNotifyFlags, omitNotifyFields } = require('../../utils/notifyFlags');
const { getPayableAmount, normalizeDiscount, sanitizeNotes } = require('../../utils/paymentAmount');
const logger = require('../../utils/logger'); const logger = require('../../utils/logger');
const getAllPayments = async (query) => { const getAllPayments = async (query) => {
@@ -69,6 +70,8 @@ const createPayment = async (body, actorId = null) => {
const payload = omitNotifyFields(body); const payload = omitNotifyFields(body);
const payment = await Payment.create({ const payment = await Payment.create({
...payload, ...payload,
discount: normalizeDiscount(payload.discount, payload.amount),
notes: sanitizeNotes(payload.notes),
paidAmount: payload.paidAmount || 0 paidAmount: payload.paidAmount || 0
}); });
@@ -110,7 +113,7 @@ const createPayment = async (body, actorId = null) => {
await sendInvoiceCreatedSms(user.phoneNumber, { await sendInvoiceCreatedSms(user.phoneNumber, {
fullName: user.name || '', fullName: user.name || '',
amount: payment.amount, amount: getPayableAmount(payment),
course: courseName || '-', course: courseName || '-',
...schedule ...schedule
}, user._id); }, user._id);
@@ -129,6 +132,12 @@ const updatePayment = async (id, body, actorId = null) => {
const previousStatus = payment.status; const previousStatus = payment.status;
Object.assign(payment, body); Object.assign(payment, body);
if (body.discount !== undefined) {
payment.discount = normalizeDiscount(body.discount, payment.amount);
}
if (body.notes !== undefined) {
payment.notes = sanitizeNotes(body.notes);
}
await payment.save(); await payment.save();
if (body.status && body.status !== previousStatus) { if (body.status && body.status !== previousStatus) {
@@ -158,6 +167,7 @@ const addTransaction = async (paymentId, trxData, actorId = null) => {
payment.transactions.push({ payment.transactions.push({
...trxData, ...trxData,
notes: sanitizeNotes(trxData.notes),
recordedBy: actorId || trxData.recordedBy, recordedBy: actorId || trxData.recordedBy,
date: trxData.date || new Date() date: trxData.date || new Date()
}); });
+56
View File
@@ -0,0 +1,56 @@
'use strict';
const config = require('../../config/config');
const logger = require('../../utils/logger');
const { Setting, SETTINGS_KEY } = require('./settingModel');
const {
isChannelEnabled,
isEnvChannelEnabled,
toPublicMessaging
} = require('../../utils/messagingChannels');
let cache = null;
let cacheAt = 0;
const CACHE_TTL_MS = 10000;
const readDbFlags = (doc) => ({
smsEnabled: doc?.smsEnabled,
emailEnabled: doc?.emailEnabled,
botEnabled: doc?.botEnabled
});
const getDbMessagingFlags = async () => {
const now = Date.now();
if (cache && (now - cacheAt) < CACHE_TTL_MS) return cache;
const doc = await Setting.findOne({ key: SETTINGS_KEY })
.select('smsEnabled emailEnabled botEnabled')
.lean();
cache = readDbFlags(doc);
cacheAt = now;
return cache;
};
const invalidateMessagingCache = () => {
cache = null;
cacheAt = 0;
};
const isMessagingChannelEnabled = async (channel) => {
if (!isEnvChannelEnabled(channel, config)) return false;
try {
const db = await getDbMessagingFlags();
return isChannelEnabled(channel, { env: config, db });
} catch (err) {
logger.warn(`[Messaging] Failed to read settings for ${channel}, using env only: ${err.message}`);
return true;
}
};
const getPublicMessaging = (doc) => toPublicMessaging(readDbFlags(doc), config);
module.exports = {
isMessagingChannelEnabled,
invalidateMessagingCache,
getPublicMessaging,
getDbMessagingFlags
};
+12
View File
@@ -15,6 +15,18 @@ const settingSchema = new mongoose.Schema({
smsTemplates: { smsTemplates: {
type: mongoose.Schema.Types.Mixed, type: mongoose.Schema.Types.Mixed,
default: {} default: {}
},
smsEnabled: {
type: Boolean,
default: true
},
emailEnabled: {
type: Boolean,
default: true
},
botEnabled: {
type: Boolean,
default: true
} }
}, { }, {
timestamps: true, timestamps: true,
+26 -4
View File
@@ -10,6 +10,11 @@ const {
resolveVariablesList, resolveVariablesList,
mergeTemplateEntry mergeTemplateEntry
} = require('./smsTemplates'); } = require('./smsTemplates');
const { parseIncomingMessaging } = require('../../utils/messagingChannels');
const {
getPublicMessaging,
invalidateMessagingCache
} = require('./messagingFlags');
const emptyTemplateMap = () => { const emptyTemplateMap = () => {
const map = {}; const map = {};
@@ -62,7 +67,8 @@ const getSettings = async () => {
const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
const storedMap = readStoredMap(doc); const storedMap = readStoredMap(doc);
return { return {
smsTemplates: toPublicTemplates(storedMap) smsTemplates: toPublicTemplates(storedMap),
messaging: getPublicMessaging(doc)
}; };
}; };
@@ -97,12 +103,16 @@ const parseIncomingEntry = (raw) => {
}; };
const saveSettings = async (body = {}) => { const saveSettings = async (body = {}) => {
const existing = await Setting.findOne({ key: SETTINGS_KEY });
const doc = existing || new Setting({ key: SETTINGS_KEY });
const hasTemplatePayload = body.smsTemplates !== undefined;
if (hasTemplatePayload) {
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])) ? Object.fromEntries(incoming.map((item) => [item.key, item]))
: incoming; : incoming;
const existing = await Setting.findOne({ key: SETTINGS_KEY });
const storedMap = existing ? readStoredMap(existing.toObject ? existing.toObject() : existing) : emptyTemplateMap(); const storedMap = existing ? readStoredMap(existing.toObject ? existing.toObject() : existing) : emptyTemplateMap();
const nextMap = {}; const nextMap = {};
@@ -127,14 +137,26 @@ const saveSettings = async (body = {}) => {
} }
} }
const doc = existing || new Setting({ key: SETTINGS_KEY });
doc.set('smsTemplates', JSON.parse(JSON.stringify(nextMap))); doc.set('smsTemplates', JSON.parse(JSON.stringify(nextMap)));
doc.markModified('smsTemplates'); doc.markModified('smsTemplates');
} else if (!existing) {
doc.set('smsTemplates', emptyTemplateMap());
}
const incomingMessaging = parseIncomingMessaging(body);
if (incomingMessaging) {
if (incomingMessaging.smsEnabled !== undefined) doc.smsEnabled = incomingMessaging.smsEnabled;
if (incomingMessaging.emailEnabled !== undefined) doc.emailEnabled = incomingMessaging.emailEnabled;
if (incomingMessaging.botEnabled !== undefined) doc.botEnabled = incomingMessaging.botEnabled;
}
await doc.save(); await doc.save();
invalidateMessagingCache();
const saved = await Setting.findOne({ key: SETTINGS_KEY }).lean(); const saved = await Setting.findOne({ key: SETTINGS_KEY }).lean();
return { return {
smsTemplates: toPublicTemplates(readStoredMap(saved)) smsTemplates: toPublicTemplates(readStoredMap(saved)),
messaging: getPublicMessaging(saved)
}; };
}; };
+7
View File
@@ -69,4 +69,11 @@ describe('SMS template variables', () => {
} }
} }
}); });
it('keeps username and password slots on the accountCreated template', () => {
const def = SMS_TEMPLATE_DEFS.find((item) => item.key === 'accountCreated');
const slots = (def?.slots || []).map((slot) => slot.key);
assert.ok(slots.includes('username'));
assert.ok(slots.includes('password'));
});
}); });
+26
View File
@@ -0,0 +1,26 @@
'use strict';
const AppError = require('../../utils/AppError');
const assertCanResetPasswordAndSms = (user) => {
if (!user) throw new AppError('USER_NOT_FOUND');
const phoneNumber = String(user.phoneNumber || user.phone || '').trim();
const username = String(user.username || '').trim();
const name = String(user.name || '').trim();
if (!phoneNumber) throw new AppError('PHONE_NUMBER_REQUIRED');
if (!username) throw new AppError('VALIDATION_FAILED', null, 'Username is missing');
return { username, phoneNumber, name };
};
const isCredentialsSmsDelivered = (sendResult) => {
if (!sendResult) return false;
return sendResult.result?.skipped !== true;
};
module.exports = {
assertCanResetPasswordAndSms,
isCredentialsSmsDelivered
};
+56
View File
@@ -0,0 +1,56 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
assertCanResetPasswordAndSms,
isCredentialsSmsDelivered
} = require('./passwordReset');
describe('assertCanResetPasswordAndSms', () => {
it('throws USER_NOT_FOUND when the user is missing', () => {
assert.throws(() => assertCanResetPasswordAndSms(null), { errorCode: 'USER_NOT_FOUND' });
assert.throws(() => assertCanResetPasswordAndSms(undefined), { errorCode: 'USER_NOT_FOUND' });
});
it('throws PHONE_NUMBER_REQUIRED when no phone number is stored', () => {
assert.throws(
() => assertCanResetPasswordAndSms({ username: 'u123456' }),
{ errorCode: 'PHONE_NUMBER_REQUIRED' }
);
assert.throws(
() => assertCanResetPasswordAndSms({ username: 'u123456', phoneNumber: ' ' }),
{ errorCode: 'PHONE_NUMBER_REQUIRED' }
);
});
it('throws VALIDATION_FAILED when username is missing', () => {
assert.throws(
() => assertCanResetPasswordAndSms({ phoneNumber: '09120000000' }),
{ errorCode: 'VALIDATION_FAILED' }
);
});
it('returns the stored username and phone number', () => {
assert.deepEqual(
assertCanResetPasswordAndSms({
username: ' u482910 ',
phoneNumber: ' 09120000000 ',
name: 'علی'
}),
{ username: 'u482910', phoneNumber: '09120000000', name: 'علی' }
);
});
});
describe('isCredentialsSmsDelivered', () => {
it('is false when sending was skipped or missing', () => {
assert.equal(isCredentialsSmsDelivered(null), false);
assert.equal(isCredentialsSmsDelivered({ result: { skipped: true } }), false);
});
it('is true when the SMS sender did not skip delivery', () => {
assert.equal(isCredentialsSmsDelivered({ result: { status: 1 } }), true);
assert.equal(isCredentialsSmsDelivered({}), true);
});
});
+5
View File
@@ -56,6 +56,11 @@ exports.search = catchAsync(async (req, res, next) => {
return listResponse(res, 200, data, meta); return listResponse(res, 200, data, meta);
}); });
exports.resetPasswordAndSendSms = catchAsync(async (req, res, next) => {
const result = await userService.resetPasswordAndSendSms(req.params.id);
return successResponse(res, 200, 'Password reset and credentials SMS processed', result);
});
exports.enroll = catchAsync(async (req, res, next) => { exports.enroll = catchAsync(async (req, res, next) => {
const { userId } = req.params; const { userId } = req.params;
const { courseId } = req.body; const { courseId } = req.body;
+1
View File
@@ -25,6 +25,7 @@ router.get('/admin/get-all', authMiddleware, perm.requires(PERMISSIONS.USERS_REA
router.get('/admin/search', authMiddleware, perm.requires(PERMISSIONS.USERS_SEARCH), userController.search); router.get('/admin/search', authMiddleware, perm.requires(PERMISSIONS.USERS_SEARCH), userController.search);
router.get('/admin/get-one/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getOne); router.get('/admin/get-one/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getOne);
router.put('/admin/update/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), validateUpdateUser, userController.update); router.put('/admin/update/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), validateUpdateUser, userController.update);
router.post('/admin/:id/reset-password-sms', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), userController.resetPasswordAndSendSms);
router.delete('/admin/delete/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_DELETE), userController.delete); router.delete('/admin/delete/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_DELETE), userController.delete);
router.post('/admin/:userId/enroll', authMiddleware, perm.requires(PERMISSIONS.USERS_ENROLL), validateEnroll, userController.enroll); router.post('/admin/:userId/enroll', authMiddleware, perm.requires(PERMISSIONS.USERS_ENROLL), validateEnroll, userController.enroll);
+37 -1
View File
@@ -8,10 +8,14 @@ const bcrypt = require('bcryptjs');
const AppError = require('../../utils/AppError'); const AppError = require('../../utils/AppError');
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
const { generateUsername, generateSimplePassword } = require('../../utils/credentials'); const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
const { sendAccountCreatedSms } = require('../../utils/senders/smsMessages'); const { sendAccountCreatedSms, sendPasswordResetSms } = require('../../utils/senders/smsMessages');
const logger = require('../../utils/logger'); const logger = require('../../utils/logger');
const { mergeFullName, normalizeGender } = require('../../utils/userProfile'); const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
const { pickNotifyFlags } = require('../../utils/notifyFlags'); const { pickNotifyFlags } = require('../../utils/notifyFlags');
const {
assertCanResetPasswordAndSms,
isCredentialsSmsDelivered
} = require('./passwordReset');
const normalizeAdminNotes = (value) => { const normalizeAdminNotes = (value) => {
if (value == null) return undefined; if (value == null) return undefined;
@@ -271,6 +275,37 @@ const deleteUser = async (id) => {
if (!user) throw new AppError('USER_NOT_FOUND'); if (!user) throw new AppError('USER_NOT_FOUND');
}; };
const resetPasswordAndSendSms = async (id) => {
const user = await User.findById(id);
if (!user) throw new AppError('USER_NOT_FOUND');
const { username, phoneNumber } = assertCanResetPasswordAndSms(user);
const plainPassword = generateSimplePassword();
user.passwordHash = await bcrypt.hash(plainPassword, 10);
user.refreshTokens = [];
await user.save();
let smsSent = false;
try {
const sendResult = await sendPasswordResetSms(phoneNumber, username, plainPassword, user._id);
smsSent = isCredentialsSmsDelivered(sendResult);
} catch (err) {
logger.error(`[resetPasswordAndSendSms] SMS failed for user=${user._id}: ${err.message}`);
}
logger.info(`[resetPasswordAndSendSms] Password reset for user=${user._id} smsSent=${smsSent}`);
return {
username,
phoneNumber,
smsSent,
generatedCredentials: {
username,
password: plainPassword
}
};
};
const enrollUserInCourse = async (userId, courseId) => { const enrollUserInCourse = async (userId, courseId) => {
const user = await User.findById(userId); const user = await User.findById(userId);
if (!user) throw new AppError('USER_NOT_FOUND'); if (!user) throw new AppError('USER_NOT_FOUND');
@@ -290,5 +325,6 @@ module.exports = {
createUserAdmin, createUserAdmin,
updateUser, updateUser,
deleteUser, deleteUser,
resetPasswordAndSendSms,
enrollUserInCourse enrollUserInCourse
}; };
+3 -1
View File
@@ -100,15 +100,17 @@ const config = {
SMTP_USER: process.env.SMTP_USER || '', SMTP_USER: process.env.SMTP_USER || '',
SMTP_PASS: process.env.SMTP_PASS || '', SMTP_PASS: process.env.SMTP_PASS || '',
EMAIL_FROM: process.env.EMAIL_FROM || 'no-reply@institution.com', EMAIL_FROM: process.env.EMAIL_FROM || 'no-reply@institution.com',
EMAIL_ENABLED: parseBool(process.env.EMAIL_ENABLED, true),
// SMS Provider Settings (sms.ir) // SMS Provider Settings (sms.ir)
SMS_ENABLED: process.env.SMS_ENABLED || 'false', SMS_ENABLED: parseBool(process.env.SMS_ENABLED, false),
SMS_PANEL_TOKEN: process.env.SMS_PANEL_TOKEN || process.env.SMS_API_KEY || '', SMS_PANEL_TOKEN: process.env.SMS_PANEL_TOKEN || process.env.SMS_API_KEY || '',
SMS_API_KEY: process.env.SMS_PANEL_TOKEN || process.env.SMS_API_KEY || 'mock_sms_key', SMS_API_KEY: process.env.SMS_PANEL_TOKEN || process.env.SMS_API_KEY || 'mock_sms_key',
SMS_SENDER_NUMBER: process.env.SMS_SENDER_NUMBER || '10001000', SMS_SENDER_NUMBER: process.env.SMS_SENDER_NUMBER || '10001000',
// Bale Messenger Bot Settings // Bale Messenger Bot Settings
BALE_BOT_TOKEN: process.env.BALE_BOT_TOKEN || 'mock_bale_bot_token', BALE_BOT_TOKEN: process.env.BALE_BOT_TOKEN || 'mock_bale_bot_token',
BOT_ENABLED: parseBool(process.env.BOT_ENABLED, true),
// SuperAdmin bootstrap (credentials come from env only; no hardcoded secrets) // SuperAdmin bootstrap (credentials come from env only; no hardcoded secrets)
SUPERADMIN_ENABLED: parseBool(process.env.SUPERADMIN_ENABLED, true), SUPERADMIN_ENABLED: parseBool(process.env.SUPERADMIN_ENABLED, true),
+1
View File
@@ -31,6 +31,7 @@ const resolveAction = (method, path = '') => {
if (p.includes('/auth/login')) return 'login'; if (p.includes('/auth/login')) return 'login';
if (p.includes('/auth/logout')) return 'logout'; if (p.includes('/auth/logout')) return 'logout';
if (p.includes('/auth/refresh')) return 'other'; if (p.includes('/auth/refresh')) return 'other';
if (p.includes('reset-password')) return 'update';
if (p.includes('enroll') || p.includes('register')) return 'enroll'; if (p.includes('enroll') || p.includes('register')) return 'enroll';
if (p.includes('attendance')) return 'attendance'; if (p.includes('attendance')) return 'attendance';
if (p.includes('upload')) return 'upload'; if (p.includes('upload')) return 'upload';
+1 -1
View File
@@ -7,7 +7,7 @@
"start": "node app.js", "start": "node app.js",
"dev": "nodemon app.js", "dev": "nodemon app.js",
"seed": "node seed.js", "seed": "node seed.js",
"test": "node --test components/settings/smsTemplates.test.js utils/classSchedule.test.js utils/notifyFlags.test.js utils/passwordRules.test.js" "test": "node --test components/settings/smsTemplates.test.js components/users/passwordReset.test.js utils/classSchedule.test.js utils/messagingChannels.test.js utils/notifyFlags.test.js utils/passwordRules.test.js utils/paymentAmount.test.js"
}, },
"keywords": [ "keywords": [
"express", "express",
+5
View File
@@ -124,6 +124,11 @@
"en": "Password must be at least 6 characters.", "en": "Password must be at least 6 characters.",
"fa": "رمز عبور باید حداقل ۶ نویسه باشد." "fa": "رمز عبور باید حداقل ۶ نویسه باشد."
}, },
"PHONE_NUMBER_REQUIRED": {
"statusCode": 400,
"en": "A phone number is required to send login credentials by SMS.",
"fa": "برای ارسال اطلاعات ورود با پیامک، شماره همراه الزامی است."
},
"TOKEN_EXPIRED": { "TOKEN_EXPIRED": {
"statusCode": 401, "statusCode": 401,
"en": "Token has expired. Please login again.", "en": "Token has expired. Please login again.",
+85
View File
@@ -0,0 +1,85 @@
'use strict';
const CHANNELS = ['sms', 'email', 'bot'];
const ENV_KEYS = {
sms: 'SMS_ENABLED',
email: 'EMAIL_ENABLED',
bot: 'BOT_ENABLED'
};
const DB_KEYS = {
sms: 'smsEnabled',
email: 'emailEnabled',
bot: 'botEnabled'
};
const toBoolean = (value, fallback = false) => {
if (typeof value === 'boolean') return value;
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'number') return value !== 0;
const normalized = String(value).trim().toLowerCase();
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
return fallback;
};
const isEnvChannelEnabled = (channel, envConfig = {}) => {
const key = ENV_KEYS[channel];
if (!key) return false;
return toBoolean(envConfig[key], false);
};
const isDbChannelEnabled = (channel, stored = {}) => {
const key = DB_KEYS[channel];
if (!key) return false;
const value = stored == null ? undefined : stored[key];
if (value === undefined || value === null || value === '') return true;
return toBoolean(value, true);
};
const isChannelEnabled = (channel, { env = {}, db = {} } = {}) => {
return isEnvChannelEnabled(channel, env) && isDbChannelEnabled(channel, db);
};
const toPublicMessaging = (stored = {}, envConfig = {}) => {
const messaging = {};
const env = {};
CHANNELS.forEach((channel) => {
messaging[DB_KEYS[channel]] = isDbChannelEnabled(channel, stored);
env[DB_KEYS[channel]] = isEnvChannelEnabled(channel, envConfig);
});
messaging.env = env;
return messaging;
};
const parseIncomingMessaging = (body = {}) => {
const source = body.messaging && typeof body.messaging === 'object'
? body.messaging
: body;
const result = {};
let hasAny = false;
CHANNELS.forEach((channel) => {
const key = DB_KEYS[channel];
if (!Object.prototype.hasOwnProperty.call(source, key) || source[key] === undefined) {
return;
}
result[key] = toBoolean(source[key], true);
hasAny = true;
});
return hasAny ? result : null;
};
module.exports = {
CHANNELS,
ENV_KEYS,
DB_KEYS,
toBoolean,
isEnvChannelEnabled,
isDbChannelEnabled,
isChannelEnabled,
toPublicMessaging,
parseIncomingMessaging
};
+98
View File
@@ -0,0 +1,98 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
isEnvChannelEnabled,
isDbChannelEnabled,
isChannelEnabled,
toPublicMessaging,
parseIncomingMessaging
} = require('./messagingChannels');
describe('isEnvChannelEnabled', () => {
it('requires SMS_ENABLED to be truthy', () => {
assert.equal(isEnvChannelEnabled('sms', { SMS_ENABLED: false }), false);
assert.equal(isEnvChannelEnabled('sms', { SMS_ENABLED: 'false' }), false);
assert.equal(isEnvChannelEnabled('sms', { SMS_ENABLED: true }), true);
assert.equal(isEnvChannelEnabled('sms', { SMS_ENABLED: 'true' }), true);
});
it('treats missing env flags as disabled', () => {
assert.equal(isEnvChannelEnabled('email', {}), false);
assert.equal(isEnvChannelEnabled('bot', {}), false);
});
});
describe('isDbChannelEnabled', () => {
it('defaults to enabled when the setting is missing', () => {
assert.equal(isDbChannelEnabled('sms', {}), true);
assert.equal(isDbChannelEnabled('email', null), true);
assert.equal(isDbChannelEnabled('bot', { botEnabled: undefined }), true);
});
it('honors explicit dashboard flags', () => {
assert.equal(isDbChannelEnabled('sms', { smsEnabled: false }), false);
assert.equal(isDbChannelEnabled('email', { emailEnabled: '0' }), false);
assert.equal(isDbChannelEnabled('bot', { botEnabled: true }), true);
});
});
describe('isChannelEnabled', () => {
it('requires both env and dashboard settings to be enabled', () => {
const envOn = { SMS_ENABLED: true, EMAIL_ENABLED: true, BOT_ENABLED: true };
const dbOn = { smsEnabled: true, emailEnabled: true, botEnabled: true };
assert.equal(isChannelEnabled('sms', { env: envOn, db: dbOn }), true);
assert.equal(isChannelEnabled('sms', {
env: { SMS_ENABLED: false },
db: { smsEnabled: true }
}), false);
assert.equal(isChannelEnabled('sms', {
env: { SMS_ENABLED: true },
db: { smsEnabled: false }
}), false);
assert.equal(isChannelEnabled('email', {
env: { EMAIL_ENABLED: true },
db: { emailEnabled: false }
}), false);
assert.equal(isChannelEnabled('bot', {
env: { BOT_ENABLED: true },
db: { botEnabled: true }
}), true);
});
});
describe('toPublicMessaging', () => {
it('returns dashboard flags plus an env snapshot', () => {
assert.deepEqual(toPublicMessaging(
{ smsEnabled: false, emailEnabled: true },
{ SMS_ENABLED: true, EMAIL_ENABLED: false, BOT_ENABLED: '1' }
), {
smsEnabled: false,
emailEnabled: true,
botEnabled: true,
env: {
smsEnabled: true,
emailEnabled: false,
botEnabled: true
}
});
});
});
describe('parseIncomingMessaging', () => {
it('reads nested messaging flags and ignores unspecified channels', () => {
assert.deepEqual(parseIncomingMessaging({
messaging: { smsEnabled: false, emailEnabled: 'true' }
}), {
smsEnabled: false,
emailEnabled: true
});
});
it('returns null when no messaging flags are present', () => {
assert.equal(parseIncomingMessaging({ smsTemplates: {} }), null);
assert.equal(parseIncomingMessaging({}), null);
});
});
+30
View File
@@ -0,0 +1,30 @@
'use strict';
const NOTES_MAX_LENGTH = 5000;
const toNonNegativeNumber = (value) => {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return 0;
return n;
};
const normalizeDiscount = (discount, amount) => {
return Math.min(toNonNegativeNumber(discount), toNonNegativeNumber(amount));
};
const getPayableAmount = (payment = {}) => {
const amount = toNonNegativeNumber(payment.amount);
return amount - normalizeDiscount(payment.discount, amount);
};
const sanitizeNotes = (notes) => {
if (notes == null) return '';
return String(notes).trim().slice(0, NOTES_MAX_LENGTH);
};
module.exports = {
NOTES_MAX_LENGTH,
getPayableAmount,
normalizeDiscount,
sanitizeNotes
};
+46
View File
@@ -0,0 +1,46 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
getPayableAmount,
normalizeDiscount,
sanitizeNotes,
NOTES_MAX_LENGTH
} = require('./paymentAmount');
describe('payment amount helpers', () => {
it('returns the original amount when there is no discount', () => {
assert.equal(getPayableAmount({ amount: 1_000_000 }), 1_000_000);
assert.equal(getPayableAmount({ amount: 1_000_000, discount: 0 }), 1_000_000);
});
it('subtracts a discount from the total', () => {
assert.equal(getPayableAmount({ amount: 1_000_000, discount: 150_000 }), 850_000);
});
it('never returns a negative payable amount', () => {
assert.equal(getPayableAmount({ amount: 100, discount: 250 }), 0);
});
it('treats missing or invalid values as zero', () => {
assert.equal(getPayableAmount({}), 0);
assert.equal(getPayableAmount({ amount: 'abc', discount: 'x' }), 0);
assert.equal(normalizeDiscount(-50, 1000), 0);
assert.equal(normalizeDiscount('not-a-number', 1000), 0);
});
it('clamps discount so it cannot exceed the total', () => {
assert.equal(normalizeDiscount(2_000, 1_000), 1_000);
assert.equal(normalizeDiscount(200, 1_000), 200);
});
});
describe('sanitizeNotes', () => {
it('trims notes and caps length', () => {
assert.equal(sanitizeNotes(' hello '), 'hello');
assert.equal(sanitizeNotes(null), '');
assert.equal(sanitizeNotes(undefined), '');
assert.equal(sanitizeNotes('a'.repeat(NOTES_MAX_LENGTH + 10)).length, NOTES_MAX_LENGTH);
});
});
+6
View File
@@ -3,11 +3,17 @@
const axios = require('axios'); const axios = require('axios');
const config = require('../../config/config'); const config = require('../../config/config');
const logger = require('../logger'); const logger = require('../logger');
const { isMessagingChannelEnabled } = require('../../components/settings/messagingFlags');
const sendBaleMessage = async ({ chatId, body }) => { const sendBaleMessage = async ({ chatId, body }) => {
try { try {
const targetChatId = chatId || 'default_channel'; const targetChatId = chatId || 'default_channel';
if (!(await isMessagingChannelEnabled('bot'))) {
logger.info(`[BaleBotSender] Skipped (channel disabled) → ${targetChatId}`);
return { skipped: true, reason: 'channel_disabled' };
}
if (config.BALE_BOT_TOKEN === 'mock_bale_bot_token') { if (config.BALE_BOT_TOKEN === 'mock_bale_bot_token') {
logger.info(`[BaleBotSender MOCK] ChatID: ${targetChatId} | Message: "${body}"`); logger.info(`[BaleBotSender MOCK] ChatID: ${targetChatId} | Message: "${body}"`);
return { success: true, messageId: `bale_mock_${Date.now()}` }; return { success: true, messageId: `bale_mock_${Date.now()}` };
+6
View File
@@ -3,6 +3,7 @@
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
const config = require('../../config/config'); const config = require('../../config/config');
const logger = require('../logger'); const logger = require('../logger');
const { isMessagingChannelEnabled } = require('../../components/settings/messagingFlags');
let transporter = null; let transporter = null;
@@ -25,6 +26,11 @@ const sendEmail = async ({ to, subject, body, html }) => {
try { try {
if (!to) throw new Error('Recipient email is required'); if (!to) throw new Error('Recipient email is required');
if (!(await isMessagingChannelEnabled('email'))) {
logger.info(`[EmailSender] Skipped (channel disabled) → ${to}`);
return { skipped: true, reason: 'channel_disabled' };
}
const mailOptions = { const mailOptions = {
from: config.EMAIL_FROM, from: config.EMAIL_FROM,
to, to,
+4 -3
View File
@@ -4,6 +4,7 @@
const axios = require('axios'); const axios = require('axios');
const config = require('../../config/config'); const config = require('../../config/config');
const logger = require('../logger'); const logger = require('../logger');
const { isMessagingChannelEnabled } = require('../../components/settings/messagingFlags');
const toBoolean = (value) => { const toBoolean = (value) => {
if (typeof value === 'boolean') return value; if (typeof value === 'boolean') return value;
@@ -28,9 +29,9 @@ const sendSingleSms = async (mobile, templateId, params = []) => {
SMS_ENABLED: config.SMS_ENABLED, SMS_ENABLED: config.SMS_ENABLED,
}); });
if (!toBoolean(config.SMS_ENABLED)) { if (!(await isMessagingChannelEnabled('sms'))) {
logger.info(`[SMS] Skipped (SMS_ENABLED=false) → ${mobile} template=${templateId}`); logger.info(`[SMS] Skipped (channel disabled) → ${mobile} template=${templateId}`);
return { skipped: true }; return { skipped: true, reason: 'channel_disabled' };
} }
if (!templateId) { if (!templateId) {
+37 -4
View File
@@ -13,7 +13,15 @@ const resolveUserIdByPhone = async (phoneNumber) => {
return user?._id || null; return user?._id || null;
}; };
const sendAccountCreatedSms = async (receiver, username, password, userId = null) => { const sendAccountCredentialsSms = async ({
receiver,
username,
password,
userId = null,
subject,
body,
relatedEvent
}) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver); const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
let fullName = ''; let fullName = '';
if (resolvedUserId) { if (resolvedUserId) {
@@ -24,9 +32,9 @@ const sendAccountCreatedSms = async (receiver, username, password, userId = null
return recordAndSend({ return recordAndSend({
userId: resolvedUserId, userId: resolvedUserId,
channel: 'sms', channel: 'sms',
subject: 'ایجاد حساب کاربری', subject,
body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`, body,
relatedEvent: 'user.created', relatedEvent,
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, { sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, {
username, username,
password, password,
@@ -38,6 +46,30 @@ const sendAccountCreatedSms = async (receiver, username, password, userId = null
}); });
}; };
const sendAccountCreatedSms = async (receiver, username, password, userId = null) => {
return sendAccountCredentialsSms({
receiver,
username,
password,
userId,
subject: 'ایجاد حساب کاربری',
body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`,
relatedEvent: 'user.created'
});
};
const sendPasswordResetSms = async (receiver, username, password, userId = null) => {
return sendAccountCredentialsSms({
receiver,
username,
password,
userId,
subject: 'بازنشانی رمز عبور',
body: `رمز عبور شما بازنشانی شد. نام کاربری: ${username}`,
relatedEvent: 'user.password_reset'
});
};
const sendClassRegisteredSms = async (receiver, className, userId = null, extraContext = {}) => { const sendClassRegisteredSms = async (receiver, className, userId = null, extraContext = {}) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver); const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
let fullName = extraContext.fullName || ''; let fullName = extraContext.fullName || '';
@@ -143,6 +175,7 @@ const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => {
module.exports = { module.exports = {
sendAccountCreatedSms, sendAccountCreatedSms,
sendPasswordResetSms,
sendClassRegisteredSms, sendClassRegisteredSms,
sendClassReminderSms, sendClassReminderSms,
sendInvoiceCreatedSms sendInvoiceCreatedSms