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:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user