// /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 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 { 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 };