Support uploading structured JSON to create courses, classes, and students without wiping existing data, and merge user name fields while adding gender and registration details from source sheets.
260 lines
7.4 KiB
JavaScript
260 lines
7.4 KiB
JavaScript
// /components/users/userService.js
|
|
'use strict';
|
|
|
|
const User = require('./userModel');
|
|
const Role = require('../roles/roleModel');
|
|
const bcrypt = require('bcryptjs');
|
|
const AppError = require('../../utils/AppError');
|
|
const { calculateMeta } = 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 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)
|
|
};
|
|
|
|
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');
|
|
return user;
|
|
};
|
|
|
|
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 = {};
|
|
if (query.search) {
|
|
filter.$or = [
|
|
{ name: new RegExp(query.search, 'i') },
|
|
{ username: new RegExp(query.search, 'i') },
|
|
{ nationalIdCode: new RegExp(query.search, 'i') },
|
|
{ phoneNumber: new RegExp(query.search, 'i') }
|
|
];
|
|
}
|
|
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: 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 {
|
|
await sendAccountCreatedSms(profile.phoneNumber, username, plainPassword);
|
|
} 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 (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') 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
|
|
};
|