diff --git a/components/activityLogs/activityLogService.js b/components/activityLogs/activityLogService.js index 8bd4a2e..c239a13 100644 --- a/components/activityLogs/activityLogService.js +++ b/components/activityLogs/activityLogService.js @@ -8,6 +8,9 @@ const { parsePaginationAndSort, calculateMeta } = require('../../utils/paginatio const SENSITIVE_KEYS = new Set([ 'password', 'passwordHash', + 'currentPassword', + 'newPassword', + 'confirmPassword', 'refreshToken', 'accessToken', 'token', diff --git a/components/auth/authController.js b/components/auth/authController.js index cf9779f..570f0fe 100644 --- a/components/auth/authController.js +++ b/components/auth/authController.js @@ -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'); +}); diff --git a/components/auth/authRoutes.js b/components/auth/authRoutes.js index 36e432c..42c1b6c 100644 --- a/components/auth/authRoutes.js +++ b/components/auth/authRoutes.js @@ -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; diff --git a/components/auth/authService.js b/components/auth/authService.js index 6f99d13..275796a 100644 --- a/components/auth/authService.js +++ b/components/auth/authService.js @@ -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 }; diff --git a/components/classes/classController.js b/components/classes/classController.js index 5a6aaa8..55786a9 100644 --- a/components/classes/classController.js +++ b/components/classes/classController.js @@ -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); diff --git a/components/classes/classRoutes.js b/components/classes/classRoutes.js index 047ba73..dd1f758 100644 --- a/components/classes/classRoutes.js +++ b/components/classes/classRoutes.js @@ -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; diff --git a/components/classes/classService.js b/components/classes/classService.js index 1ba7c2e..350968f 100644 --- a/components/classes/classService.js +++ b/components/classes/classService.js @@ -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 }; diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js index a81569a..f7106cf 100644 --- a/components/payments/paymentService.js +++ b/components/payments/paymentService.js @@ -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); diff --git a/components/users/userController.js b/components/users/userController.js index 89642b4..1a653bb 100644 --- a/components/users/userController.js +++ b/components/users/userController.js @@ -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); diff --git a/components/users/userService.js b/components/users/userService.js index a5b5413..50a883a 100644 --- a/components/users/userService.js +++ b/components/users/userService.js @@ -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}`); } diff --git a/package.json b/package.json index 0c9b6b7..a733e51 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" + "test": "node --test components/settings/smsTemplates.test.js utils/classSchedule.test.js utils/notifyFlags.test.js utils/passwordRules.test.js" }, "keywords": [ "express", diff --git a/utils/errors.json b/utils/errors.json index b382b58..19284aa 100644 --- a/utils/errors.json +++ b/utils/errors.json @@ -114,6 +114,16 @@ "en": "Invalid username or password.", "fa": "نام کاربری یا رمز عبور اشتباه است." }, + "INVALID_CURRENT_PASSWORD": { + "statusCode": 401, + "en": "Current password is incorrect.", + "fa": "رمز عبور فعلی اشتباه است." + }, + "WEAK_PASSWORD": { + "statusCode": 400, + "en": "Password must be at least 6 characters.", + "fa": "رمز عبور باید حداقل ۶ نویسه باشد." + }, "TOKEN_EXPIRED": { "statusCode": 401, "en": "Token has expired. Please login again.", diff --git a/utils/notifyFlags.js b/utils/notifyFlags.js new file mode 100644 index 0000000..c566715 --- /dev/null +++ b/utils/notifyFlags.js @@ -0,0 +1,49 @@ +// /utils/notifyFlags.js +'use strict'; + +const NOTIFY_FIELD_KEYS = [ + 'notify', + 'notifySms', + 'notifyEmail', + 'notifyBot', + 'sendSms', + 'sendEmail', + 'sendBot' +]; + +const toFlag = (value, fallback = true) => { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['false', '0', 'no', 'off'].includes(normalized)) return false; + if (['true', '1', 'yes', 'on'].includes(normalized)) return true; + } + return fallback; +}; + +const pickNotifyFlags = (source = {}) => { + const nested = source && typeof source.notify === 'object' && source.notify !== null + ? source.notify + : {}; + + return { + sms: toFlag(nested.sms ?? source.notifySms ?? source.sendSms, true), + email: toFlag(nested.email ?? source.notifyEmail ?? source.sendEmail, true), + bot: toFlag(nested.bot ?? source.notifyBot ?? source.sendBot, true) + }; +}; + +const omitNotifyFields = (source = {}) => { + const next = { ...source }; + NOTIFY_FIELD_KEYS.forEach((key) => { + delete next[key]; + }); + return next; +}; + +module.exports = { + pickNotifyFlags, + omitNotifyFields +}; diff --git a/utils/notifyFlags.test.js b/utils/notifyFlags.test.js new file mode 100644 index 0000000..874a85f --- /dev/null +++ b/utils/notifyFlags.test.js @@ -0,0 +1,56 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { pickNotifyFlags, omitNotifyFields } = require('./notifyFlags'); + +describe('pickNotifyFlags', () => { + it('defaults all channels to enabled when the body is empty', () => { + assert.deepEqual(pickNotifyFlags(), { sms: true, email: true, bot: true }); + assert.deepEqual(pickNotifyFlags({}), { sms: true, email: true, bot: true }); + }); + + it('reads nested notify flags and keeps unspecified channels enabled', () => { + assert.deepEqual(pickNotifyFlags({ notify: { sms: false } }), { + sms: false, + email: true, + bot: true + }); + }); + + it('honors explicit false for email and bot', () => { + assert.deepEqual(pickNotifyFlags({ + notify: { sms: true, email: false, bot: false } + }), { + sms: true, + email: false, + bot: false + }); + }); + + it('accepts top-level aliases and string booleans', () => { + assert.deepEqual(pickNotifyFlags({ + notifySms: 'false', + notifyEmail: 'true', + sendBot: '0' + }), { + sms: false, + email: true, + bot: false + }); + }); +}); + +describe('omitNotifyFields', () => { + it('strips notify fields without mutating the original body', () => { + const body = { + user: 'abc', + amount: 1000, + notify: { sms: false }, + notifySms: false + }; + const cleaned = omitNotifyFields(body); + assert.deepEqual(cleaned, { user: 'abc', amount: 1000 }); + assert.equal(body.notifySms, false); + }); +}); diff --git a/utils/passwordRules.js b/utils/passwordRules.js new file mode 100644 index 0000000..9aea008 --- /dev/null +++ b/utils/passwordRules.js @@ -0,0 +1,17 @@ +// /utils/passwordRules.js +'use strict'; + +const AppError = require('./AppError'); + +const MIN_PASSWORD_LENGTH = 6; + +const assertPasswordStrength = (password) => { + if (typeof password !== 'string' || password.trim().length < MIN_PASSWORD_LENGTH) { + throw new AppError('WEAK_PASSWORD'); + } +}; + +module.exports = { + MIN_PASSWORD_LENGTH, + assertPasswordStrength +}; diff --git a/utils/passwordRules.test.js b/utils/passwordRules.test.js new file mode 100644 index 0000000..166517c --- /dev/null +++ b/utils/passwordRules.test.js @@ -0,0 +1,18 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { assertPasswordStrength, MIN_PASSWORD_LENGTH } = require('./passwordRules'); + +describe('assertPasswordStrength', () => { + it(`rejects passwords shorter than ${MIN_PASSWORD_LENGTH} characters`, () => { + assert.throws(() => assertPasswordStrength('ab12'), { errorCode: 'WEAK_PASSWORD' }); + assert.throws(() => assertPasswordStrength(''), { errorCode: 'WEAK_PASSWORD' }); + assert.throws(() => assertPasswordStrength(null), { errorCode: 'WEAK_PASSWORD' }); + }); + + it('accepts passwords that meet the minimum length', () => { + assert.doesNotThrow(() => assertPasswordStrength('ab1234')); + assert.doesNotThrow(() => assertPasswordStrength('longer-secret')); + }); +});