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