Let admins skip SMS on user, class, and invoice actions, and let users change their own password with the current one.
295 lines
8.7 KiB
JavaScript
295 lines
8.7 KiB
JavaScript
// /components/users/userService.js
|
|
'use strict';
|
|
|
|
const User = require('./userModel');
|
|
const Role = require('../roles/roleModel');
|
|
const Class = require('../classes/classModel');
|
|
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 logger = require('../../utils/logger');
|
|
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
|
|
const { pickNotifyFlags } = require('../../utils/notifyFlags');
|
|
|
|
const normalizeAdminNotes = (value) => {
|
|
if (value == null) return undefined;
|
|
if (!Array.isArray(value)) return undefined;
|
|
return value.map((n) => String(n).trim()).filter(Boolean);
|
|
};
|
|
|
|
const withUserListExtras = async (users) => {
|
|
if (!users.length) return users;
|
|
const userIds = users.map((u) => u._id);
|
|
const counts = await Class.aggregate([
|
|
{ $match: { students: { $in: userIds } } },
|
|
{ $unwind: '$students' },
|
|
{ $match: { students: { $in: userIds } } },
|
|
{ $group: { _id: '$students', count: { $sum: 1 } } }
|
|
]);
|
|
const countMap = Object.fromEntries(counts.map((c) => [String(c._id), c.count]));
|
|
return users.map((u) => ({
|
|
...u,
|
|
nationalId: u.nationalIdCode,
|
|
registeredClassesCount: countMap[String(u._id)] || 0
|
|
}));
|
|
};
|
|
|
|
const POPULATE_ROLE = { path: 'role', select: 'name permissions' };
|
|
const SAFE_FIELDS = '-passwordHash -refreshTokens';
|
|
const ALLOWED_MESSENGERS = ['Bale', 'WhatsApp', 'Telegram', 'SMS', 'Email'];
|
|
|
|
const normalizePreferredMessengers = (value) => {
|
|
if (value == null || value === '') return undefined;
|
|
const list = Array.isArray(value) ? value : [value];
|
|
const cleaned = [...new Set(list.map(String).filter((v) => ALLOWED_MESSENGERS.includes(v)))];
|
|
return cleaned;
|
|
};
|
|
|
|
const allocateUniqueUsername = async (preferred) => {
|
|
let username = preferred && String(preferred).trim();
|
|
if (username) {
|
|
const exists = await User.exists({ username });
|
|
if (exists) throw new AppError('USER_ALREADY_EXISTS', null, 'Username already exists');
|
|
return username;
|
|
}
|
|
|
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
username = generateUsername();
|
|
const exists = await User.exists({ username });
|
|
if (!exists) return username;
|
|
}
|
|
throw new AppError('INTERNAL_SERVER_ERROR', null, 'Could not generate a unique username');
|
|
};
|
|
|
|
const pickProfileFields = (body = {}) => {
|
|
const fullName = mergeFullName(body.name, body.surname);
|
|
const fields = {
|
|
name: fullName || undefined,
|
|
gender: normalizeGender(body.gender),
|
|
nationalIdCode: body.nationalIdCode || body.nationalId || undefined,
|
|
phoneNumber: body.phoneNumber || body.phone || undefined,
|
|
email: body.email,
|
|
address: body.address,
|
|
birthCertificateNumber: body.birthCertificateNumber,
|
|
postalCode: body.postalCode,
|
|
placeOfIssue: body.placeOfIssue,
|
|
fatherName: body.fatherName,
|
|
birthDate: body.birthDate || undefined,
|
|
education: body.education,
|
|
parentPhoneNumber: body.parentPhoneNumber,
|
|
preferredMessenger: normalizePreferredMessengers(body.preferredMessenger),
|
|
adminNotes: normalizeAdminNotes(body.adminNotes)
|
|
};
|
|
|
|
Object.keys(fields).forEach((key) => {
|
|
if (fields[key] === '' || fields[key] === null || fields[key] === undefined) {
|
|
delete fields[key];
|
|
}
|
|
});
|
|
|
|
return fields;
|
|
};
|
|
|
|
const signUp = async (body) => {
|
|
const profile = pickProfileFields(body);
|
|
const { username, password } = body;
|
|
|
|
const userRole = await Role.findOne({ name: 'User' });
|
|
if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
|
|
|
|
const passwordHash = await bcrypt.hash(password, 10);
|
|
const user = await User.create({
|
|
...profile,
|
|
username,
|
|
passwordHash,
|
|
role: userRole._id
|
|
});
|
|
|
|
return user.populate(POPULATE_ROLE);
|
|
};
|
|
|
|
const getUserById = async (id) => {
|
|
const user = await User.findById(id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean();
|
|
if (!user) throw new AppError('USER_NOT_FOUND');
|
|
const [enriched] = await withUserListExtras([user]);
|
|
return enriched;
|
|
};
|
|
|
|
const getAllUsers = async (query) => {
|
|
const page = parseInt(query.page) || 1;
|
|
const limit = Math.min(parseInt(query.limit) || 20, 200);
|
|
const skip = (page - 1) * limit;
|
|
|
|
const filter = {};
|
|
const searchTerm = getSearchTerm(query);
|
|
if (searchTerm) {
|
|
const searchRegex = new RegExp(escapeRegex(searchTerm), 'i');
|
|
filter.$or = [
|
|
{ name: searchRegex },
|
|
{ username: searchRegex },
|
|
{ nationalIdCode: searchRegex },
|
|
{ phoneNumber: searchRegex },
|
|
{ email: searchRegex }
|
|
];
|
|
}
|
|
if (query.isActive !== undefined) filter.isActive = query.isActive === 'true';
|
|
|
|
const [items, total] = await Promise.all([
|
|
User.find(filter).select(SAFE_FIELDS).populate(POPULATE_ROLE).skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
|
|
User.countDocuments(filter)
|
|
]);
|
|
|
|
return { data: await withUserListExtras(items), meta: calculateMeta(total, page, limit) };
|
|
};
|
|
|
|
const searchUsers = async (query) => getAllUsers(query);
|
|
|
|
const createUserAdmin = async (body) => {
|
|
const profile = pickProfileFields(body);
|
|
const {
|
|
username: requestedUsername,
|
|
password: requestedPassword,
|
|
roleId,
|
|
role
|
|
} = body;
|
|
|
|
let roleObj = null;
|
|
if (roleId || role) {
|
|
roleObj = await Role.findById(roleId || role);
|
|
}
|
|
if (!roleObj) {
|
|
roleObj = await Role.findOne({ name: 'User' });
|
|
}
|
|
if (!roleObj) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
|
|
|
|
const plainPassword = (requestedPassword && String(requestedPassword).trim()) || generateSimplePassword();
|
|
const username = await allocateUniqueUsername(requestedUsername);
|
|
const passwordHash = await bcrypt.hash(plainPassword, 10);
|
|
|
|
const user = await User.create({
|
|
...profile,
|
|
username,
|
|
passwordHash,
|
|
role: roleObj._id
|
|
});
|
|
|
|
try {
|
|
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}`);
|
|
}
|
|
|
|
const created = await User.findById(user._id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean();
|
|
return {
|
|
...created,
|
|
generatedCredentials: {
|
|
username,
|
|
password: plainPassword
|
|
}
|
|
};
|
|
};
|
|
|
|
const updateUser = async (id, body) => {
|
|
const {
|
|
password,
|
|
nationalId,
|
|
nationalIdCode,
|
|
phone,
|
|
phoneNumber,
|
|
roleId,
|
|
role,
|
|
username,
|
|
passwordHash,
|
|
refreshTokens,
|
|
preferredMessenger,
|
|
surname,
|
|
name,
|
|
gender,
|
|
_id,
|
|
id: bodyId,
|
|
createdAt,
|
|
updatedAt,
|
|
__v,
|
|
...rest
|
|
} = body;
|
|
|
|
const update = { ...rest };
|
|
const mergedName = mergeFullName(name, surname);
|
|
if (mergedName) update.name = mergedName;
|
|
|
|
const normalizedGender = normalizeGender(gender);
|
|
if (normalizedGender) update.gender = normalizedGender;
|
|
|
|
if (nationalIdCode || nationalId) {
|
|
update.nationalIdCode = nationalIdCode || nationalId;
|
|
}
|
|
if (phoneNumber || phone) {
|
|
update.phoneNumber = phoneNumber || phone;
|
|
}
|
|
if (roleId || role) {
|
|
update.role = roleId || role;
|
|
}
|
|
if (preferredMessenger !== undefined) {
|
|
update.preferredMessenger = normalizePreferredMessengers(preferredMessenger) || [];
|
|
}
|
|
if (body.adminNotes !== undefined) {
|
|
update.adminNotes = normalizeAdminNotes(body.adminNotes) || [];
|
|
}
|
|
|
|
if (typeof username === 'string' && username.trim()) {
|
|
update.username = username.trim();
|
|
}
|
|
|
|
// Legacy field — drop if clients still send it
|
|
delete update.surname;
|
|
|
|
Object.keys(update).forEach((key) => {
|
|
if (key === 'preferredMessenger' || key === 'adminNotes') return;
|
|
if (update[key] === '' || update[key] === null || update[key] === undefined) {
|
|
delete update[key];
|
|
}
|
|
});
|
|
|
|
if (password) {
|
|
update.passwordHash = await bcrypt.hash(password, 10);
|
|
}
|
|
|
|
const user = await User.findByIdAndUpdate(id, update, { new: true, runValidators: true })
|
|
.select(SAFE_FIELDS)
|
|
.populate(POPULATE_ROLE)
|
|
.lean();
|
|
if (!user) throw new AppError('USER_NOT_FOUND');
|
|
return user;
|
|
};
|
|
|
|
const deleteUser = async (id) => {
|
|
const user = await User.findByIdAndDelete(id);
|
|
if (!user) throw new AppError('USER_NOT_FOUND');
|
|
};
|
|
|
|
const enrollUserInCourse = async (userId, courseId) => {
|
|
const user = await User.findById(userId);
|
|
if (!user) throw new AppError('USER_NOT_FOUND');
|
|
|
|
if (!user.courses.includes(courseId)) {
|
|
user.courses.push(courseId);
|
|
await user.save();
|
|
}
|
|
return User.findById(userId).select(SAFE_FIELDS).populate('courses').lean();
|
|
};
|
|
|
|
module.exports = {
|
|
signUp,
|
|
getUserById,
|
|
getAllUsers,
|
|
searchUsers,
|
|
createUserAdmin,
|
|
updateUser,
|
|
deleteUser,
|
|
enrollUserInCourse
|
|
};
|