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:
@@ -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