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([
|
const SENSITIVE_KEYS = new Set([
|
||||||
'password',
|
'password',
|
||||||
'passwordHash',
|
'passwordHash',
|
||||||
|
'currentPassword',
|
||||||
|
'newPassword',
|
||||||
|
'confirmPassword',
|
||||||
'refreshToken',
|
'refreshToken',
|
||||||
'accessToken',
|
'accessToken',
|
||||||
'token',
|
'token',
|
||||||
|
|||||||
@@ -22,3 +22,13 @@ exports.logout = catchAsync(async (req, res, next) => {
|
|||||||
await authService.logout(userId, refreshToken);
|
await authService.logout(userId, refreshToken);
|
||||||
return successResponse(res, 200, 'Logout successful');
|
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('/login', validateLogin, authController.login);
|
||||||
router.post('/refresh', validateRefresh, authController.refresh);
|
router.post('/refresh', validateRefresh, authController.refresh);
|
||||||
router.post('/logout', authMiddleware, authController.logout);
|
router.post('/logout', authMiddleware, authController.logout);
|
||||||
|
router.put('/change-password', authMiddleware, authController.changePassword);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const User = require('../users/userModel');
|
|||||||
const config = require('../../config/config');
|
const config = require('../../config/config');
|
||||||
const AppError = require('../../utils/AppError');
|
const AppError = require('../../utils/AppError');
|
||||||
const { isBootstrapSuperAdminDisabled } = require('../../utils/superAdmin');
|
const { isBootstrapSuperAdminDisabled } = require('../../utils/superAdmin');
|
||||||
|
const { assertPasswordStrength } = require('../../utils/passwordRules');
|
||||||
|
|
||||||
const generateTokens = (user) => {
|
const generateTokens = (user) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -104,8 +105,37 @@ const logout = async (userId, refreshTokenString) => {
|
|||||||
return true;
|
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 = {
|
module.exports = {
|
||||||
login,
|
login,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
logout
|
logout,
|
||||||
|
changePassword
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,10 +31,15 @@ exports.delete = catchAsync(async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
exports.registerUsers = 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);
|
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) => {
|
exports.getMyClasses = catchAsync(async (req, res) => {
|
||||||
const { data, meta } = await classService.getMyClasses(req.user._id, req.query);
|
const { data, meta } = await classService.getMyClasses(req.user._id, req.query);
|
||||||
return listResponse(res, 200, data, meta);
|
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.put('/admin/update/:id', perm.requires(PERMISSIONS.CLASSES_UPDATE), classController.update);
|
||||||
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.CLASSES_DELETE), classController.delete);
|
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.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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const AppError = require('../../utils/AppError');
|
|||||||
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
|
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
|
||||||
const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages');
|
const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages');
|
||||||
const { buildClassScheduleContext } = require('../../utils/classSchedule');
|
const { buildClassScheduleContext } = require('../../utils/classSchedule');
|
||||||
|
const { pickNotifyFlags } = require('../../utils/notifyFlags');
|
||||||
const logger = require('../../utils/logger');
|
const logger = require('../../utils/logger');
|
||||||
|
|
||||||
const getAll = async (query) => {
|
const getAll = async (query) => {
|
||||||
@@ -64,10 +65,11 @@ const remove = async (id) => {
|
|||||||
if (!cls) throw new AppError('CLASS_NOT_FOUND');
|
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' });
|
const cls = await Class.findById(classId).populate({ path: 'course', select: 'title' });
|
||||||
if (!cls) throw new AppError('CLASS_NOT_FOUND');
|
if (!cls) throw new AppError('CLASS_NOT_FOUND');
|
||||||
|
|
||||||
|
const notify = pickNotifyFlags(notifyInput);
|
||||||
const toAdd = (userIds || []).filter(
|
const toAdd = (userIds || []).filter(
|
||||||
(id) => !cls.students.map((s) => s.toString()).includes(id.toString())
|
(id) => !cls.students.map((s) => s.toString()).includes(id.toString())
|
||||||
);
|
);
|
||||||
@@ -78,26 +80,41 @@ const registerUsers = async (classId, userIds) => {
|
|||||||
cls.students.push(...toAdd);
|
cls.students.push(...toAdd);
|
||||||
await cls.save();
|
await cls.save();
|
||||||
|
|
||||||
const classLabel = cls.name || cls.course?.title || 'کلاس';
|
if (notify.sms) {
|
||||||
const sessions = await Session.find({ class: classId })
|
const classLabel = cls.name || cls.course?.title || 'کلاس';
|
||||||
.select('day startTime endTime')
|
const sessions = await Session.find({ class: classId })
|
||||||
.sort({ day: 1 })
|
.select('day startTime endTime')
|
||||||
.lean();
|
.sort({ day: 1 })
|
||||||
const schedule = buildClassScheduleContext(cls, sessions);
|
.lean();
|
||||||
const users = await User.find({ _id: { $in: toAdd } }).select('phoneNumber').lean();
|
const schedule = buildClassScheduleContext(cls, sessions);
|
||||||
await Promise.all(
|
const users = await User.find({ _id: { $in: toAdd } }).select('phoneNumber').lean();
|
||||||
users.map(async (user) => {
|
await Promise.all(
|
||||||
if (!user.phoneNumber) return;
|
users.map(async (user) => {
|
||||||
try {
|
if (!user.phoneNumber) return;
|
||||||
await sendClassRegisteredSms(user.phoneNumber, classLabel, user._id, {
|
try {
|
||||||
courseName: cls.course?.title || classLabel,
|
await sendClassRegisteredSms(user.phoneNumber, classLabel, user._id, {
|
||||||
...schedule
|
courseName: cls.course?.title || classLabel,
|
||||||
});
|
...schedule
|
||||||
} catch (err) {
|
});
|
||||||
logger.error(`[registerUsers] SMS failed for ${user.phoneNumber}: ${err.message}`);
|
} 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);
|
return getOne(classId);
|
||||||
};
|
};
|
||||||
@@ -121,4 +138,4 @@ const getMyClasses = async (userId, query = {}) => {
|
|||||||
return { data: items, meta: calculateMeta(total, page, limit) };
|
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 Session = require('../sessions/sessionModel');
|
||||||
const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages');
|
const { sendInvoiceCreatedSms } = require('../../utils/senders/smsMessages');
|
||||||
const { buildClassScheduleContext } = require('../../utils/classSchedule');
|
const { buildClassScheduleContext } = require('../../utils/classSchedule');
|
||||||
|
const { pickNotifyFlags, omitNotifyFields } = require('../../utils/notifyFlags');
|
||||||
const logger = require('../../utils/logger');
|
const logger = require('../../utils/logger');
|
||||||
|
|
||||||
const getAllPayments = async (query) => {
|
const getAllPayments = async (query) => {
|
||||||
@@ -64,9 +65,11 @@ const getPaymentById = async (id) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createPayment = async (body, actorId = null) => {
|
const createPayment = async (body, actorId = null) => {
|
||||||
|
const notify = pickNotifyFlags(body);
|
||||||
|
const payload = omitNotifyFields(body);
|
||||||
const payment = await Payment.create({
|
const payment = await Payment.create({
|
||||||
...body,
|
...payload,
|
||||||
paidAmount: body.paidAmount || 0
|
paidAmount: payload.paidAmount || 0
|
||||||
});
|
});
|
||||||
|
|
||||||
if (actorId) {
|
if (actorId) {
|
||||||
@@ -77,42 +80,44 @@ const createPayment = async (body, actorId = null) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (notify.sms) {
|
||||||
const user = await User.findById(payment.user).select('name phoneNumber').lean();
|
try {
|
||||||
if (user?.phoneNumber) {
|
const user = await User.findById(payment.user).select('name phoneNumber').lean();
|
||||||
let courseName = '';
|
if (user?.phoneNumber) {
|
||||||
let schedule = { classStartDate: '', classDays: '', courseTime: '' };
|
let courseName = '';
|
||||||
if (payment.course) {
|
let schedule = { classStartDate: '', classDays: '', courseTime: '' };
|
||||||
const course = await Course.findById(payment.course).select('title').lean();
|
if (payment.course) {
|
||||||
if (course?.title) courseName = course.title;
|
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 (payment.classes && payment.classes.length > 0) {
|
||||||
if (!courseName) {
|
const cls = await Class.findById(payment.classes[0]).populate('course', 'title').lean();
|
||||||
if (cls?.course?.title) {
|
if (!courseName) {
|
||||||
courseName = cls.course.title;
|
if (cls?.course?.title) {
|
||||||
} else if (cls?.name) {
|
courseName = cls.course.title;
|
||||||
courseName = cls.name;
|
} 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, {
|
await sendInvoiceCreatedSms(user.phoneNumber, {
|
||||||
fullName: user.name || '',
|
fullName: user.name || '',
|
||||||
amount: payment.amount,
|
amount: payment.amount,
|
||||||
course: courseName || '-',
|
course: courseName || '-',
|
||||||
...schedule
|
...schedule
|
||||||
}, user._id);
|
}, 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);
|
return getPaymentById(payment._id);
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ exports.getSelf = catchAsync(async (req, res, next) => {
|
|||||||
exports.updateSelf = catchAsync(async (req, res, next) => {
|
exports.updateSelf = catchAsync(async (req, res, next) => {
|
||||||
delete req.body.role;
|
delete req.body.role;
|
||||||
delete req.body.isActive;
|
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);
|
const user = await userService.updateUser(req.user._id, req.body);
|
||||||
return successResponse(res, 200, 'Profile updated successfully', user);
|
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 { sendAccountCreatedSms } = require('../../utils/senders/smsMessages');
|
||||||
const logger = require('../../utils/logger');
|
const logger = require('../../utils/logger');
|
||||||
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
|
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
|
||||||
|
const { pickNotifyFlags } = require('../../utils/notifyFlags');
|
||||||
|
|
||||||
const normalizeAdminNotes = (value) => {
|
const normalizeAdminNotes = (value) => {
|
||||||
if (value == null) return undefined;
|
if (value == null) return undefined;
|
||||||
@@ -175,7 +176,9 @@ const createUserAdmin = async (body) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendAccountCreatedSms(profile.phoneNumber, username, plainPassword, user._id);
|
if (pickNotifyFlags(body).sms) {
|
||||||
|
await sendAccountCreatedSms(profile.phoneNumber, username, plainPassword, user._id);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`[createUserAdmin] Account SMS failed for ${profile.phoneNumber}: ${err.message}`);
|
logger.error(`[createUserAdmin] Account SMS failed for ${profile.phoneNumber}: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"start": "node app.js",
|
"start": "node app.js",
|
||||||
"dev": "nodemon app.js",
|
"dev": "nodemon app.js",
|
||||||
"seed": "node seed.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": [
|
"keywords": [
|
||||||
"express",
|
"express",
|
||||||
|
|||||||
@@ -114,6 +114,16 @@
|
|||||||
"en": "Invalid username or password.",
|
"en": "Invalid username or password.",
|
||||||
"fa": "نام کاربری یا رمز عبور اشتباه است."
|
"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": {
|
"TOKEN_EXPIRED": {
|
||||||
"statusCode": 401,
|
"statusCode": 401,
|
||||||
"en": "Token has expired. Please login again.",
|
"en": "Token has expired. Please login again.",
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user