Files
gameno-api/components/users/userService.js
T

359 lines
11 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, sendPasswordResetSms } = require('../../utils/senders/smsMessages');
const logger = require('../../utils/logger');
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
const { resolveNotifyFlags } = require('../../utils/notifyResolver');
const { notifyAction } = require('../../utils/actionNotify');
const {
assertCanResetPasswordAndSms,
isCredentialsSmsDelivered
} = require('./passwordReset');
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 {
const notify = await resolveNotifyFlags(body, 'accountCreated');
if (notify.sms || notify.email || notify.bot) {
await notifyAction({
actionKey: 'accountCreated',
userId: user._id,
phoneNumber: profile.phoneNumber,
email: profile.email,
subject: 'ایجاد حساب کاربری',
body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username} — کد کاربری: ${user.uniqueCode || ''}`,
smsHandler: () => sendAccountCreatedSms(
profile.phoneNumber,
username,
plainPassword,
user._id,
user.uniqueCode
),
requestSource: body
});
}
} 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 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 notify = await resolveNotifyFlags({}, 'passwordReset');
if (notify.sms || notify.email || notify.bot) {
const sendResult = await notifyAction({
actionKey: 'passwordReset',
userId: user._id,
phoneNumber,
email: user.email,
subject: 'بازنشانی رمز عبور',
body: `رمز عبور شما بازنشانی شد. نام کاربری: ${username} — کد کاربری: ${user.uniqueCode || ''}`,
smsHandler: () => sendPasswordResetSms(phoneNumber, username, plainPassword, user._id, user.uniqueCode)
});
smsSent = isCredentialsSmsDelivered(sendResult?.sms);
}
} 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');
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,
resetPasswordAndSendSms,
enrollUserInCourse
};