feat: add password change, class unenroll, and optional notify flags
Let admins skip SMS on user, class, and invoice actions, and let users change their own password with the current one.
This commit is contained in:
@@ -8,6 +8,9 @@ const { parsePaginationAndSort, calculateMeta } = require('../../utils/paginatio
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
'password',
|
||||
'passwordHash',
|
||||
'currentPassword',
|
||||
'newPassword',
|
||||
'confirmPassword',
|
||||
'refreshToken',
|
||||
'accessToken',
|
||||
'token',
|
||||
|
||||
@@ -22,3 +22,13 @@ exports.logout = catchAsync(async (req, res, next) => {
|
||||
await authService.logout(userId, refreshToken);
|
||||
return successResponse(res, 200, 'Logout successful');
|
||||
});
|
||||
|
||||
exports.changePassword = catchAsync(async (req, res) => {
|
||||
const { currentPassword, newPassword, refreshToken } = req.body;
|
||||
await authService.changePassword(req.user._id, {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
refreshToken
|
||||
});
|
||||
return successResponse(res, 200, 'Password changed successfully');
|
||||
});
|
||||
|
||||
@@ -10,5 +10,6 @@ const router = express.Router();
|
||||
router.post('/login', validateLogin, authController.login);
|
||||
router.post('/refresh', validateRefresh, authController.refresh);
|
||||
router.post('/logout', authMiddleware, authController.logout);
|
||||
router.put('/change-password', authMiddleware, authController.changePassword);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -6,6 +6,7 @@ const User = require('../users/userModel');
|
||||
const config = require('../../config/config');
|
||||
const AppError = require('../../utils/AppError');
|
||||
const { isBootstrapSuperAdminDisabled } = require('../../utils/superAdmin');
|
||||
const { assertPasswordStrength } = require('../../utils/passwordRules');
|
||||
|
||||
const generateTokens = (user) => {
|
||||
const payload = {
|
||||
@@ -104,8 +105,37 @@ const logout = async (userId, refreshTokenString) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const changePassword = async (userId, { currentPassword, newPassword, refreshToken: currentRefreshToken }) => {
|
||||
if (!currentPassword) {
|
||||
throw new AppError('INVALID_CURRENT_PASSWORD');
|
||||
}
|
||||
assertPasswordStrength(newPassword);
|
||||
|
||||
const user = await User.findById(userId);
|
||||
if (!user || !user.isActive || isBootstrapSuperAdminDisabled(user)) {
|
||||
throw new AppError('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!isMatch) {
|
||||
throw new AppError('INVALID_CURRENT_PASSWORD');
|
||||
}
|
||||
|
||||
user.passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
if (currentRefreshToken) {
|
||||
user.refreshTokens = user.refreshTokens.filter((rt) => rt.token === currentRefreshToken);
|
||||
} else {
|
||||
user.refreshTokens = [];
|
||||
}
|
||||
|
||||
await user.save();
|
||||
return true;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
login,
|
||||
refreshToken,
|
||||
logout
|
||||
logout,
|
||||
changePassword
|
||||
};
|
||||
|
||||
@@ -31,10 +31,15 @@ exports.delete = catchAsync(async (req, res) => {
|
||||
});
|
||||
|
||||
exports.registerUsers = catchAsync(async (req, res) => {
|
||||
const cls = await classService.registerUsers(req.params.id, req.body.userIds || []);
|
||||
const cls = await classService.registerUsers(req.params.id, req.body.userIds || [], req.body);
|
||||
return successResponse(res, 200, 'Users registered in class successfully', cls);
|
||||
});
|
||||
|
||||
exports.removeUser = catchAsync(async (req, res) => {
|
||||
const cls = await classService.removeUser(req.params.id, req.params.userId);
|
||||
return successResponse(res, 200, 'User removed from class successfully', cls);
|
||||
});
|
||||
|
||||
exports.getMyClasses = catchAsync(async (req, res) => {
|
||||
const { data, meta } = await classService.getMyClasses(req.user._id, req.query);
|
||||
return listResponse(res, 200, data, meta);
|
||||
|
||||
@@ -21,5 +21,6 @@ router.post('/admin/create', perm.requires(PERMISSIONS.CLASSES_CREATE), classCon
|
||||
router.put('/admin/update/:id', perm.requires(PERMISSIONS.CLASSES_UPDATE), classController.update);
|
||||
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.CLASSES_DELETE), classController.delete);
|
||||
router.post('/admin/:id/register-users', perm.requires(PERMISSIONS.CLASSES_REGISTER_USERS), classController.registerUsers);
|
||||
router.delete('/admin/:id/students/:userId', perm.requires(PERMISSIONS.CLASSES_REGISTER_USERS), classController.removeUser);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -8,6 +8,7 @@ const AppError = require('../../utils/AppError');
|
||||
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
|
||||
const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages');
|
||||
const { buildClassScheduleContext } = require('../../utils/classSchedule');
|
||||
const { pickNotifyFlags } = require('../../utils/notifyFlags');
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
const getAll = async (query) => {
|
||||
@@ -64,10 +65,11 @@ const remove = async (id) => {
|
||||
if (!cls) throw new AppError('CLASS_NOT_FOUND');
|
||||
};
|
||||
|
||||
const registerUsers = async (classId, userIds) => {
|
||||
const registerUsers = async (classId, userIds, notifyInput = {}) => {
|
||||
const cls = await Class.findById(classId).populate({ path: 'course', select: 'title' });
|
||||
if (!cls) throw new AppError('CLASS_NOT_FOUND');
|
||||
|
||||
const notify = pickNotifyFlags(notifyInput);
|
||||
const toAdd = (userIds || []).filter(
|
||||
(id) => !cls.students.map((s) => s.toString()).includes(id.toString())
|
||||
);
|
||||
@@ -78,26 +80,41 @@ const registerUsers = async (classId, userIds) => {
|
||||
cls.students.push(...toAdd);
|
||||
await cls.save();
|
||||
|
||||
const classLabel = cls.name || cls.course?.title || 'کلاس';
|
||||
const sessions = await Session.find({ class: classId })
|
||||
.select('day startTime endTime')
|
||||
.sort({ day: 1 })
|
||||
.lean();
|
||||
const schedule = buildClassScheduleContext(cls, sessions);
|
||||
const users = await User.find({ _id: { $in: toAdd } }).select('phoneNumber').lean();
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
if (!user.phoneNumber) return;
|
||||
try {
|
||||
await sendClassRegisteredSms(user.phoneNumber, classLabel, user._id, {
|
||||
courseName: cls.course?.title || classLabel,
|
||||
...schedule
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`[registerUsers] SMS failed for ${user.phoneNumber}: ${err.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
if (notify.sms) {
|
||||
const classLabel = cls.name || cls.course?.title || 'کلاس';
|
||||
const sessions = await Session.find({ class: classId })
|
||||
.select('day startTime endTime')
|
||||
.sort({ day: 1 })
|
||||
.lean();
|
||||
const schedule = buildClassScheduleContext(cls, sessions);
|
||||
const users = await User.find({ _id: { $in: toAdd } }).select('phoneNumber').lean();
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
if (!user.phoneNumber) return;
|
||||
try {
|
||||
await sendClassRegisteredSms(user.phoneNumber, classLabel, user._id, {
|
||||
courseName: cls.course?.title || classLabel,
|
||||
...schedule
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`[registerUsers] SMS failed for ${user.phoneNumber}: ${err.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return getOne(classId);
|
||||
};
|
||||
|
||||
const removeUser = async (classId, userId) => {
|
||||
const cls = await Class.findById(classId);
|
||||
if (!cls) throw new AppError('CLASS_NOT_FOUND');
|
||||
|
||||
const before = cls.students.length;
|
||||
cls.students = cls.students.filter((id) => id.toString() !== String(userId));
|
||||
if (cls.students.length !== before) {
|
||||
await cls.save();
|
||||
}
|
||||
|
||||
return getOne(classId);
|
||||
};
|
||||
@@ -121,4 +138,4 @@ const getMyClasses = async (userId, query = {}) => {
|
||||
return { data: items, meta: calculateMeta(total, page, limit) };
|
||||
};
|
||||
|
||||
module.exports = { getAll, getOne, create, update, remove, registerUsers, getMyClasses };
|
||||
module.exports = { getAll, getOne, create, update, remove, registerUsers, removeUser, getMyClasses };
|
||||
|
||||
@@ -12,6 +12,7 @@ const Class = require('../classes/classModel');
|
||||
const Session = require('../sessions/sessionModel');
|
||||
const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages');
|
||||
const { buildClassScheduleContext } = require('../../utils/classSchedule');
|
||||
const { pickNotifyFlags, omitNotifyFields } = require('../../utils/notifyFlags');
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
const getAllPayments = async (query) => {
|
||||
@@ -64,9 +65,11 @@ const getPaymentById = async (id) => {
|
||||
};
|
||||
|
||||
const createPayment = async (body, actorId = null) => {
|
||||
const notify = pickNotifyFlags(body);
|
||||
const payload = omitNotifyFields(body);
|
||||
const payment = await Payment.create({
|
||||
...body,
|
||||
paidAmount: body.paidAmount || 0
|
||||
...payload,
|
||||
paidAmount: payload.paidAmount || 0
|
||||
});
|
||||
|
||||
if (actorId) {
|
||||
@@ -77,42 +80,44 @@ const createPayment = async (body, actorId = null) => {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findById(payment.user).select('name phoneNumber').lean();
|
||||
if (user?.phoneNumber) {
|
||||
let courseName = '';
|
||||
let schedule = { classStartDate: '', classDays: '', courseTime: '' };
|
||||
if (payment.course) {
|
||||
const course = await Course.findById(payment.course).select('title').lean();
|
||||
if (course?.title) courseName = course.title;
|
||||
}
|
||||
if (payment.classes && payment.classes.length > 0) {
|
||||
const cls = await Class.findById(payment.classes[0]).populate('course', 'title').lean();
|
||||
if (!courseName) {
|
||||
if (cls?.course?.title) {
|
||||
courseName = cls.course.title;
|
||||
} else if (cls?.name) {
|
||||
courseName = cls.name;
|
||||
if (notify.sms) {
|
||||
try {
|
||||
const user = await User.findById(payment.user).select('name phoneNumber').lean();
|
||||
if (user?.phoneNumber) {
|
||||
let courseName = '';
|
||||
let schedule = { classStartDate: '', classDays: '', courseTime: '' };
|
||||
if (payment.course) {
|
||||
const course = await Course.findById(payment.course).select('title').lean();
|
||||
if (course?.title) courseName = course.title;
|
||||
}
|
||||
if (payment.classes && payment.classes.length > 0) {
|
||||
const cls = await Class.findById(payment.classes[0]).populate('course', 'title').lean();
|
||||
if (!courseName) {
|
||||
if (cls?.course?.title) {
|
||||
courseName = cls.course.title;
|
||||
} else if (cls?.name) {
|
||||
courseName = cls.name;
|
||||
}
|
||||
}
|
||||
if (cls) {
|
||||
const sessions = await Session.find({ class: cls._id })
|
||||
.select('day startTime endTime')
|
||||
.sort({ day: 1 })
|
||||
.lean();
|
||||
schedule = buildClassScheduleContext(cls, sessions);
|
||||
}
|
||||
}
|
||||
if (cls) {
|
||||
const sessions = await Session.find({ class: cls._id })
|
||||
.select('day startTime endTime')
|
||||
.sort({ day: 1 })
|
||||
.lean();
|
||||
schedule = buildClassScheduleContext(cls, sessions);
|
||||
}
|
||||
}
|
||||
|
||||
await sendInvoiceCreatedSms(user.phoneNumber, {
|
||||
fullName: user.name || '',
|
||||
amount: payment.amount,
|
||||
course: courseName || '-',
|
||||
...schedule
|
||||
}, user._id);
|
||||
await sendInvoiceCreatedSms(user.phoneNumber, {
|
||||
fullName: user.name || '',
|
||||
amount: payment.amount,
|
||||
course: courseName || '-',
|
||||
...schedule
|
||||
}, user._id);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[createPayment] Invoice SMS failed for payment ${payment._id}: ${err.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[createPayment] Invoice SMS failed for payment ${payment._id}: ${err.message}`);
|
||||
}
|
||||
|
||||
return getPaymentById(payment._id);
|
||||
|
||||
@@ -17,6 +17,10 @@ exports.getSelf = catchAsync(async (req, res, next) => {
|
||||
exports.updateSelf = catchAsync(async (req, res, next) => {
|
||||
delete req.body.role;
|
||||
delete req.body.isActive;
|
||||
delete req.body.password;
|
||||
delete req.body.passwordHash;
|
||||
delete req.body.refreshTokens;
|
||||
delete req.body.username;
|
||||
|
||||
const user = await userService.updateUser(req.user._id, req.body);
|
||||
return successResponse(res, 200, 'Profile updated successfully', user);
|
||||
|
||||
@@ -11,6 +11,7 @@ const { generateUsername, generateSimplePassword } = require('../../utils/creden
|
||||
const { sendAccountCreatedSms } = require('../../utils/senders/smsMessages');
|
||||
const logger = require('../../utils/logger');
|
||||
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
|
||||
const { pickNotifyFlags } = require('../../utils/notifyFlags');
|
||||
|
||||
const normalizeAdminNotes = (value) => {
|
||||
if (value == null) return undefined;
|
||||
@@ -175,7 +176,9 @@ const createUserAdmin = async (body) => {
|
||||
});
|
||||
|
||||
try {
|
||||
await sendAccountCreatedSms(profile.phoneNumber, username, plainPassword, user._id);
|
||||
if (pickNotifyFlags(body).sms) {
|
||||
await sendAccountCreatedSms(profile.phoneNumber, username, plainPassword, user._id);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[createUserAdmin] Account SMS failed for ${profile.phoneNumber}: ${err.message}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user