From 72be01fee300ccf6fe425046447df7021a198e9d Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Sun, 16 Aug 2026 06:58:08 +0330 Subject: [PATCH] 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. --- .env.example | 3 + components/activityLogs/activityLogService.js | 3 +- components/payments/paymentModel.js | 16 ++- components/payments/paymentService.js | 12 ++- components/settings/messagingFlags.js | 56 +++++++++++ components/settings/settingModel.js | 12 +++ components/settings/settingService.js | 78 +++++++++------ components/settings/smsTemplates.test.js | 7 ++ components/users/passwordReset.js | 26 +++++ components/users/passwordReset.test.js | 56 +++++++++++ components/users/userController.js | 5 + components/users/userRoutes.js | 1 + components/users/userService.js | 38 ++++++- config/config.js | 4 +- middlewares/activityLogger.js | 1 + package.json | 2 +- utils/errors.json | 5 + utils/messagingChannels.js | 85 ++++++++++++++++ utils/messagingChannels.test.js | 98 +++++++++++++++++++ utils/paymentAmount.js | 30 ++++++ utils/paymentAmount.test.js | 46 +++++++++ utils/senders/baleBotSender.js | 6 ++ utils/senders/emailSender.js | 6 ++ utils/senders/sms.base.js | 7 +- utils/senders/smsMessages.js | 41 +++++++- 25 files changed, 601 insertions(+), 43 deletions(-) create mode 100644 components/settings/messagingFlags.js create mode 100644 components/users/passwordReset.js create mode 100644 components/users/passwordReset.test.js create mode 100644 utils/messagingChannels.js create mode 100644 utils/messagingChannels.test.js create mode 100644 utils/paymentAmount.js create mode 100644 utils/paymentAmount.test.js diff --git a/.env.example b/.env.example index b87e58e..a6e59f4 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,8 @@ SMTP_PORT=2525 SMTP_USER=your_smtp_user SMTP_PASS=your_smtp_password 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_ENABLED=false @@ -75,6 +77,7 @@ SMS_SENDER_NUMBER=10001000 # Bale Messenger Bot Token BALE_BOT_TOKEN=mock_bale_bot_token +BOT_ENABLED=true # SuperAdmin bootstrap (created automatically in production) # Set SUPERADMIN_ENABLED=false to deactivate the bootstrap SuperAdmin and block login diff --git a/components/activityLogs/activityLogService.js b/components/activityLogs/activityLogService.js index c239a13..d1d5ecc 100644 --- a/components/activityLogs/activityLogService.js +++ b/components/activityLogs/activityLogService.js @@ -16,7 +16,8 @@ const SENSITIVE_KEYS = new Set([ 'token', 'secret', 'smtp_pass', - 'SMTP_PASS' + 'SMTP_PASS', + 'generatedCredentials' ]); const sanitizeValue = (value, depth = 0) => { diff --git a/components/payments/paymentModel.js b/components/payments/paymentModel.js index 4aa30b0..7f3552a 100644 --- a/components/payments/paymentModel.js +++ b/components/payments/paymentModel.js @@ -3,6 +3,8 @@ const mongoose = require('mongoose'); +const { getPayableAmount, normalizeDiscount } = require('../../utils/paymentAmount'); + const transactionSchema = new mongoose.Schema({ amount: { type: Number, required: true }, method: { @@ -11,6 +13,7 @@ const transactionSchema = new mongoose.Schema({ default: 'card' }, receiptNumber: { type: String, trim: true }, + notes: { type: String, trim: true, maxlength: 5000 }, recordedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, date: { type: Date, default: Date.now } }, { _id: true }); @@ -35,6 +38,11 @@ const paymentSchema = new mongoose.Schema({ required: true, min: 0 }, + discount: { + type: Number, + default: 0, + min: 0 + }, paidAmount: { type: Number, default: 0, @@ -49,14 +57,16 @@ const paymentSchema = new mongoose.Schema({ default: 'pending' }, transactions: [transactionSchema], - notes: { type: String, trim: true } + notes: { type: String, trim: true, maxlength: 5000 } }, { 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) { - if (this.paidAmount >= this.amount) { + this.discount = normalizeDiscount(this.discount, this.amount); + const payable = getPayableAmount(this); + if (this.paidAmount >= payable) { this.status = 'paid'; } else if (this.paidAmount > 0) { this.status = 'partial'; diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index f7106cf..5055eef 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -13,6 +13,7 @@ const Session = require('../sessions/sessionModel'); const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages'); const { buildClassScheduleContext } = require('../../utils/classSchedule'); const { pickNotifyFlags, omitNotifyFields } = require('../../utils/notifyFlags'); +const { getPayableAmount, normalizeDiscount, sanitizeNotes } = require('../../utils/paymentAmount'); const logger = require('../../utils/logger'); const getAllPayments = async (query) => { @@ -69,6 +70,8 @@ const createPayment = async (body, actorId = null) => { const payload = omitNotifyFields(body); const payment = await Payment.create({ ...payload, + discount: normalizeDiscount(payload.discount, payload.amount), + notes: sanitizeNotes(payload.notes), paidAmount: payload.paidAmount || 0 }); @@ -110,7 +113,7 @@ const createPayment = async (body, actorId = null) => { await sendInvoiceCreatedSms(user.phoneNumber, { fullName: user.name || '', - amount: payment.amount, + amount: getPayableAmount(payment), course: courseName || '-', ...schedule }, user._id); @@ -129,6 +132,12 @@ const updatePayment = async (id, body, actorId = null) => { const previousStatus = payment.status; 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(); if (body.status && body.status !== previousStatus) { @@ -158,6 +167,7 @@ const addTransaction = async (paymentId, trxData, actorId = null) => { payment.transactions.push({ ...trxData, + notes: sanitizeNotes(trxData.notes), recordedBy: actorId || trxData.recordedBy, date: trxData.date || new Date() }); diff --git a/components/settings/messagingFlags.js b/components/settings/messagingFlags.js new file mode 100644 index 0000000..601044a --- /dev/null +++ b/components/settings/messagingFlags.js @@ -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 +}; diff --git a/components/settings/settingModel.js b/components/settings/settingModel.js index 04aa694..49c5380 100644 --- a/components/settings/settingModel.js +++ b/components/settings/settingModel.js @@ -15,6 +15,18 @@ const settingSchema = new mongoose.Schema({ smsTemplates: { type: mongoose.Schema.Types.Mixed, default: {} + }, + smsEnabled: { + type: Boolean, + default: true + }, + emailEnabled: { + type: Boolean, + default: true + }, + botEnabled: { + type: Boolean, + default: true } }, { timestamps: true, diff --git a/components/settings/settingService.js b/components/settings/settingService.js index 7152718..eec995f 100644 --- a/components/settings/settingService.js +++ b/components/settings/settingService.js @@ -10,6 +10,11 @@ const { resolveVariablesList, mergeTemplateEntry } = require('./smsTemplates'); +const { parseIncomingMessaging } = require('../../utils/messagingChannels'); +const { + getPublicMessaging, + invalidateMessagingCache +} = require('./messagingFlags'); const emptyTemplateMap = () => { const map = {}; @@ -62,7 +67,8 @@ const getSettings = async () => { const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); const storedMap = readStoredMap(doc); return { - smsTemplates: toPublicTemplates(storedMap) + smsTemplates: toPublicTemplates(storedMap), + messaging: getPublicMessaging(doc) }; }; @@ -97,44 +103,60 @@ const parseIncomingEntry = (raw) => { }; const saveSettings = async (body = {}) => { - const incoming = body.smsTemplates || {}; - const incomingMap = Array.isArray(incoming) - ? Object.fromEntries(incoming.map((item) => [item.key, item])) - : incoming; - const existing = await Setting.findOne({ key: SETTINGS_KEY }); - const storedMap = existing ? readStoredMap(existing.toObject ? existing.toObject() : existing) : emptyTemplateMap(); - const nextMap = {}; + const doc = existing || new Setting({ key: SETTINGS_KEY }); + const hasTemplatePayload = body.smsTemplates !== undefined; - for (const def of SMS_TEMPLATE_DEFS) { - const hasIncoming = Object.prototype.hasOwnProperty.call(incomingMap, def.key); - const incomingEntry = hasIncoming ? parseIncomingEntry(incomingMap[def.key]) : null; + if (hasTemplatePayload) { + const incoming = body.smsTemplates || {}; + const incomingMap = Array.isArray(incoming) + ? Object.fromEntries(incoming.map((item) => [item.key, item])) + : incoming; - 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} نامعتبر است`); + const storedMap = existing ? readStoredMap(existing.toObject ? existing.toObject() : existing) : emptyTemplateMap(); + const nextMap = {}; + + for (const def of SMS_TEMPLATE_DEFS) { + const hasIncoming = Object.prototype.hasOwnProperty.call(incomingMap, def.key); + const incomingEntry = hasIncoming ? parseIncomingEntry(incomingMap[def.key]) : 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} نامعتبر است`); + } + if (err.code === 'INVALID_VARIABLE_NAME') { + throw new AppError( + 'VALIDATION_FAILED', + { field: err.field || def.key }, + `نام متغیر «${err.rawName}» برای ${def.label} نامعتبر است` + ); + } + throw err; } - if (err.code === 'INVALID_VARIABLE_NAME') { - throw new AppError( - 'VALIDATION_FAILED', - { field: err.field || def.key }, - `نام متغیر «${err.rawName}» برای ${def.label} نامعتبر است` - ); - } - throw err; } + + doc.set('smsTemplates', JSON.parse(JSON.stringify(nextMap))); + 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; } - const doc = existing || new Setting({ key: SETTINGS_KEY }); - doc.set('smsTemplates', JSON.parse(JSON.stringify(nextMap))); - doc.markModified('smsTemplates'); await doc.save(); + invalidateMessagingCache(); const saved = await Setting.findOne({ key: SETTINGS_KEY }).lean(); return { - smsTemplates: toPublicTemplates(readStoredMap(saved)) + smsTemplates: toPublicTemplates(readStoredMap(saved)), + messaging: getPublicMessaging(saved) }; }; diff --git a/components/settings/smsTemplates.test.js b/components/settings/smsTemplates.test.js index 421487e..e0aa9e0 100644 --- a/components/settings/smsTemplates.test.js +++ b/components/settings/smsTemplates.test.js @@ -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')); + }); }); diff --git a/components/users/passwordReset.js b/components/users/passwordReset.js new file mode 100644 index 0000000..00f1ad1 --- /dev/null +++ b/components/users/passwordReset.js @@ -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 +}; diff --git a/components/users/passwordReset.test.js b/components/users/passwordReset.test.js new file mode 100644 index 0000000..49ca01c --- /dev/null +++ b/components/users/passwordReset.test.js @@ -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); + }); +}); diff --git a/components/users/userController.js b/components/users/userController.js index 1a653bb..721565b 100644 --- a/components/users/userController.js +++ b/components/users/userController.js @@ -56,6 +56,11 @@ exports.search = catchAsync(async (req, res, next) => { 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) => { const { userId } = req.params; const { courseId } = req.body; diff --git a/components/users/userRoutes.js b/components/users/userRoutes.js index 4fa6f1e..da83bb1 100644 --- a/components/users/userRoutes.js +++ b/components/users/userRoutes.js @@ -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/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.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.post('/admin/:userId/enroll', authMiddleware, perm.requires(PERMISSIONS.USERS_ENROLL), validateEnroll, userController.enroll); diff --git a/components/users/userService.js b/components/users/userService.js index 50a883a..c21fd86 100644 --- a/components/users/userService.js +++ b/components/users/userService.js @@ -8,10 +8,14 @@ const bcrypt = require('bcryptjs'); const AppError = require('../../utils/AppError'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); 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 { mergeFullName, normalizeGender } = require('../../utils/userProfile'); const { pickNotifyFlags } = require('../../utils/notifyFlags'); +const { + assertCanResetPasswordAndSms, + isCredentialsSmsDelivered +} = require('./passwordReset'); const normalizeAdminNotes = (value) => { if (value == null) return undefined; @@ -271,6 +275,37 @@ const deleteUser = async (id) => { 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 user = await User.findById(userId); if (!user) throw new AppError('USER_NOT_FOUND'); @@ -290,5 +325,6 @@ module.exports = { createUserAdmin, updateUser, deleteUser, + resetPasswordAndSendSms, enrollUserInCourse }; diff --git a/config/config.js b/config/config.js index 5bc69f0..79a959f 100644 --- a/config/config.js +++ b/config/config.js @@ -100,15 +100,17 @@ const config = { SMTP_USER: process.env.SMTP_USER || '', SMTP_PASS: process.env.SMTP_PASS || '', EMAIL_FROM: process.env.EMAIL_FROM || 'no-reply@institution.com', + EMAIL_ENABLED: parseBool(process.env.EMAIL_ENABLED, true), // 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_API_KEY: process.env.SMS_PANEL_TOKEN || process.env.SMS_API_KEY || 'mock_sms_key', SMS_SENDER_NUMBER: process.env.SMS_SENDER_NUMBER || '10001000', // Bale Messenger Bot Settings 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_ENABLED: parseBool(process.env.SUPERADMIN_ENABLED, true), diff --git a/middlewares/activityLogger.js b/middlewares/activityLogger.js index 9f12bdb..d17ced4 100644 --- a/middlewares/activityLogger.js +++ b/middlewares/activityLogger.js @@ -31,6 +31,7 @@ const resolveAction = (method, path = '') => { if (p.includes('/auth/login')) return 'login'; if (p.includes('/auth/logout')) return 'logout'; 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('attendance')) return 'attendance'; if (p.includes('upload')) return 'upload'; diff --git a/package.json b/package.json index a733e51..33c522a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "node app.js", "dev": "nodemon app.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": [ "express", diff --git a/utils/errors.json b/utils/errors.json index 19284aa..cb580ad 100644 --- a/utils/errors.json +++ b/utils/errors.json @@ -124,6 +124,11 @@ "en": "Password must be at least 6 characters.", "fa": "رمز عبور باید حداقل ۶ نویسه باشد." }, + "PHONE_NUMBER_REQUIRED": { + "statusCode": 400, + "en": "A phone number is required to send login credentials by SMS.", + "fa": "برای ارسال اطلاعات ورود با پیامک، شماره همراه الزامی است." + }, "TOKEN_EXPIRED": { "statusCode": 401, "en": "Token has expired. Please login again.", diff --git a/utils/messagingChannels.js b/utils/messagingChannels.js new file mode 100644 index 0000000..02789ca --- /dev/null +++ b/utils/messagingChannels.js @@ -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 +}; diff --git a/utils/messagingChannels.test.js b/utils/messagingChannels.test.js new file mode 100644 index 0000000..70f1c64 --- /dev/null +++ b/utils/messagingChannels.test.js @@ -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); + }); +}); diff --git a/utils/paymentAmount.js b/utils/paymentAmount.js new file mode 100644 index 0000000..f85fd09 --- /dev/null +++ b/utils/paymentAmount.js @@ -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 +}; diff --git a/utils/paymentAmount.test.js b/utils/paymentAmount.test.js new file mode 100644 index 0000000..2b06e53 --- /dev/null +++ b/utils/paymentAmount.test.js @@ -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); + }); +}); diff --git a/utils/senders/baleBotSender.js b/utils/senders/baleBotSender.js index f8e7dd5..1c498d7 100644 --- a/utils/senders/baleBotSender.js +++ b/utils/senders/baleBotSender.js @@ -3,11 +3,17 @@ const axios = require('axios'); const config = require('../../config/config'); const logger = require('../logger'); +const { isMessagingChannelEnabled } = require('../../components/settings/messagingFlags'); const sendBaleMessage = async ({ chatId, body }) => { try { 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') { logger.info(`[BaleBotSender MOCK] ChatID: ${targetChatId} | Message: "${body}"`); return { success: true, messageId: `bale_mock_${Date.now()}` }; diff --git a/utils/senders/emailSender.js b/utils/senders/emailSender.js index 52539d6..0acafc0 100644 --- a/utils/senders/emailSender.js +++ b/utils/senders/emailSender.js @@ -3,6 +3,7 @@ const nodemailer = require('nodemailer'); const config = require('../../config/config'); const logger = require('../logger'); +const { isMessagingChannelEnabled } = require('../../components/settings/messagingFlags'); let transporter = null; @@ -25,6 +26,11 @@ const sendEmail = async ({ to, subject, body, html }) => { try { 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 = { from: config.EMAIL_FROM, to, diff --git a/utils/senders/sms.base.js b/utils/senders/sms.base.js index e2f5f20..73f389d 100644 --- a/utils/senders/sms.base.js +++ b/utils/senders/sms.base.js @@ -4,6 +4,7 @@ const axios = require('axios'); const config = require('../../config/config'); const logger = require('../logger'); +const { isMessagingChannelEnabled } = require('../../components/settings/messagingFlags'); const toBoolean = (value) => { if (typeof value === 'boolean') return value; @@ -28,9 +29,9 @@ const sendSingleSms = async (mobile, templateId, params = []) => { SMS_ENABLED: config.SMS_ENABLED, }); - if (!toBoolean(config.SMS_ENABLED)) { - logger.info(`[SMS] Skipped (SMS_ENABLED=false) → ${mobile} template=${templateId}`); - return { skipped: true }; + if (!(await isMessagingChannelEnabled('sms'))) { + logger.info(`[SMS] Skipped (channel disabled) → ${mobile} template=${templateId}`); + return { skipped: true, reason: 'channel_disabled' }; } if (!templateId) { diff --git a/utils/senders/smsMessages.js b/utils/senders/smsMessages.js index 0ce7cda..a442af9 100644 --- a/utils/senders/smsMessages.js +++ b/utils/senders/smsMessages.js @@ -13,7 +13,15 @@ const resolveUserIdByPhone = async (phoneNumber) => { 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); let fullName = ''; if (resolvedUserId) { @@ -24,9 +32,9 @@ const sendAccountCreatedSms = async (receiver, username, password, userId = null return recordAndSend({ userId: resolvedUserId, channel: 'sms', - subject: 'ایجاد حساب کاربری', - body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`, - relatedEvent: 'user.created', + subject, + body, + relatedEvent, sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, { username, 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 resolvedUserId = userId || await resolveUserIdByPhone(receiver); let fullName = extraContext.fullName || ''; @@ -143,6 +175,7 @@ const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => { module.exports = { sendAccountCreatedSms, + sendPasswordResetSms, sendClassRegisteredSms, sendClassReminderSms, sendInvoiceCreatedSms