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:
@@ -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
|
||||
|
||||
@@ -16,7 +16,8 @@ const SENSITIVE_KEYS = new Set([
|
||||
'token',
|
||||
'secret',
|
||||
'smtp_pass',
|
||||
'SMTP_PASS'
|
||||
'SMTP_PASS',
|
||||
'generatedCredentials'
|
||||
]);
|
||||
|
||||
const sanitizeValue = (value, depth = 0) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
+3
-1
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()}` };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user