624 lines
19 KiB
JavaScript
624 lines
19 KiB
JavaScript
// /components/users/userService.js
|
|
'use strict';
|
|
|
|
const User = require('./userModel');
|
|
const Role = require('../roles/roleModel');
|
|
const Class = require('../classes/classModel');
|
|
const Session = require('../sessions/sessionModel');
|
|
const Payment = require('../payments/paymentModel');
|
|
const Transaction = require('../payments/transactionModel');
|
|
const Certificate = require('../certificates/certificateModel');
|
|
const Document = require('../documents/documentModel');
|
|
const Waitlist = require('../waitlist/waitlistModel');
|
|
const Notification = require('../notifications/notificationModel');
|
|
const ActivityLog = require('../activityLogs/activityLogModel');
|
|
const professorService = require('../professors/professorService');
|
|
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 { allocatePlaceholderNationalId } = require('../../utils/nationalId');
|
|
|
|
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,
|
|
cardNumber: body.cardNumber,
|
|
shabaNumber: body.shabaNumber || body.iban,
|
|
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');
|
|
|
|
if (!profile.nationalIdCode) {
|
|
profile.nationalIdCode = await allocatePlaceholderNationalId(profile.phoneNumber);
|
|
}
|
|
|
|
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');
|
|
|
|
if (!profile.nationalIdCode) {
|
|
profile.nationalIdCode = await allocatePlaceholderNationalId(profile.phoneNumber);
|
|
}
|
|
|
|
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.shabaNumber !== undefined || body.iban !== undefined) {
|
|
update.shabaNumber = (body.shabaNumber || body.iban || '').trim() || undefined;
|
|
delete update.iban;
|
|
}
|
|
if (body.cardNumber !== undefined) {
|
|
update.cardNumber = (body.cardNumber || '').trim() || undefined;
|
|
}
|
|
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();
|
|
};
|
|
|
|
const getUserFullProfile = async (id) => {
|
|
const user = await User.findById(id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean();
|
|
if (!user) throw new AppError('USER_NOT_FOUND');
|
|
|
|
// 1. Enrolled Classes
|
|
const classes = await Class.find({ students: id, isDeleted: { $ne: true } })
|
|
.populate('course', 'title type price hoursPerSection')
|
|
.populate('professor', 'name surname phoneNumber email')
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
|
|
const classIds = classes.map((c) => c._id);
|
|
|
|
// 2. All Sessions for user's classes or where user is in attendanceList
|
|
const sessions = await Session.find({
|
|
$or: [
|
|
{ class: { $in: classIds } },
|
|
{ 'attendanceList.user': id }
|
|
],
|
|
isDeleted: { $ne: true }
|
|
})
|
|
.populate('course', 'title')
|
|
.populate('class', 'name')
|
|
.populate('professor', 'name surname')
|
|
.populate('attendanceList.recordedBy', 'name username')
|
|
.sort({ day: -1, startTime: -1 })
|
|
.lean();
|
|
|
|
// Map attendance records specifically for this student
|
|
let presentCount = 0;
|
|
let absentCount = 0;
|
|
let lateCount = 0;
|
|
let excusedCount = 0;
|
|
|
|
const userAttendances = sessions.map((sess) => {
|
|
const record = (sess.attendanceList || []).find((r) => String(r.user) === String(id));
|
|
const status = record ? record.status : 'scheduled';
|
|
if (status === 'present') presentCount++;
|
|
else if (status === 'absent') absentCount++;
|
|
else if (status === 'late') lateCount++;
|
|
else if (status === 'excused') excusedCount++;
|
|
|
|
return {
|
|
_id: sess._id,
|
|
sessionId: sess._id,
|
|
day: sess.day,
|
|
startTime: sess.startTime,
|
|
endTime: sess.endTime,
|
|
place: sess.place,
|
|
topic: sess.topic,
|
|
sessionStatus: sess.status,
|
|
course: sess.course,
|
|
class: sess.class,
|
|
professor: sess.professor,
|
|
attendanceStatus: status,
|
|
attendanceNote: record?.note || '',
|
|
recordedBy: record?.recordedBy || null
|
|
};
|
|
});
|
|
|
|
const totalRecordedAttendance = presentCount + absentCount + lateCount + excusedCount;
|
|
const attendanceRate = totalRecordedAttendance > 0
|
|
? Math.round(((presentCount + lateCount) / totalRecordedAttendance) * 100)
|
|
: 0;
|
|
|
|
// 3. Payments & Invoices
|
|
const payments = await Payment.find({ user: id, isDeleted: { $ne: true } })
|
|
.populate('classes', 'name tuitionFee')
|
|
.populate('course', 'title')
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
|
|
const paymentIds = payments.map((p) => p._id);
|
|
|
|
// 4. Bank Transactions
|
|
const transactions = await Transaction.find({
|
|
$or: [
|
|
{ user: id },
|
|
{ payment: { $in: paymentIds } }
|
|
]
|
|
})
|
|
.populate('payment', 'uniqueCode amount status')
|
|
.populate('recordedBy', 'name username')
|
|
.sort({ date: -1, createdAt: -1 })
|
|
.lean();
|
|
|
|
// 5. Financial Summary Calculations
|
|
let totalTuition = 0;
|
|
let totalDiscount = 0;
|
|
let totalPayable = 0;
|
|
let totalPaid = 0;
|
|
let overdueAmount = 0;
|
|
let pendingAmount = 0;
|
|
|
|
const paymentStatusMap = {
|
|
paid: { count: 0, amount: 0 },
|
|
partial: { count: 0, amount: 0 },
|
|
pending: { count: 0, amount: 0 },
|
|
overdue: { count: 0, amount: 0 },
|
|
cancelled: { count: 0, amount: 0 },
|
|
reverted: { count: 0, amount: 0 }
|
|
};
|
|
|
|
payments.forEach((p) => {
|
|
const payable = Math.max(0, (p.amount || 0) - (p.discount || 0));
|
|
totalTuition += (p.amount || 0);
|
|
totalDiscount += (p.discount || 0);
|
|
totalPayable += payable;
|
|
totalPaid += (p.paidAmount || 0);
|
|
|
|
const st = p.status || 'pending';
|
|
if (paymentStatusMap[st]) {
|
|
paymentStatusMap[st].count++;
|
|
paymentStatusMap[st].amount += payable;
|
|
}
|
|
|
|
if (st === 'overdue') {
|
|
overdueAmount += Math.max(0, payable - (p.paidAmount || 0));
|
|
} else if (st === 'pending' || st === 'partial') {
|
|
pendingAmount += Math.max(0, payable - (p.paidAmount || 0));
|
|
}
|
|
});
|
|
|
|
const remainingDebt = Math.max(0, totalPayable - totalPaid);
|
|
|
|
const financialSummary = {
|
|
totalTuition,
|
|
totalDiscount,
|
|
totalPayable,
|
|
totalPaid,
|
|
remainingDebt,
|
|
overdueAmount,
|
|
pendingAmount,
|
|
totalInvoicesCount: payments.length,
|
|
totalTransactionsCount: transactions.length
|
|
};
|
|
|
|
// Monthly transaction timeline for charts
|
|
const monthlyTimelineMap = {};
|
|
transactions.forEach((t) => {
|
|
if (t.status !== 'cancelled' && t.status !== 'reverted') {
|
|
const d = t.date || t.createdAt;
|
|
if (d) {
|
|
const monthKey = new Date(d).toISOString().slice(0, 7); // YYYY-MM
|
|
if (!monthlyTimelineMap[monthKey]) {
|
|
monthlyTimelineMap[monthKey] = { month: monthKey, amount: 0, count: 0 };
|
|
}
|
|
monthlyTimelineMap[monthKey].amount += (t.amount || 0);
|
|
monthlyTimelineMap[monthKey].count++;
|
|
}
|
|
}
|
|
});
|
|
|
|
const monthlyTransactions = Object.values(monthlyTimelineMap).sort((a, b) => a.month.localeCompare(b.month));
|
|
|
|
// 6. Certificates & Documents
|
|
const certificates = await Certificate.find({ user: id })
|
|
.populate('course', 'title')
|
|
.sort({ issuedAt: -1 })
|
|
.lean();
|
|
|
|
const documents = await Document.find({ user: id })
|
|
.populate('uploadedBy', 'name username')
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
|
|
// 7. Waitlist Entries
|
|
const waitlist = await Waitlist.find({ user: id, isDeleted: { $ne: true } })
|
|
.populate('course', 'title type price')
|
|
.populate('class', 'name startDate')
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
|
|
// 8. Notifications
|
|
const notifications = await Notification.find({ user: id })
|
|
.sort({ createdAt: -1 })
|
|
.limit(50)
|
|
.lean();
|
|
|
|
// 9. Activity Logs
|
|
const activityLogs = await ActivityLog.find({
|
|
$or: [
|
|
{ actor: id },
|
|
{ resourceId: String(id) },
|
|
{ 'metadata.userId': String(id) }
|
|
]
|
|
})
|
|
.sort({ createdAt: -1 })
|
|
.limit(50)
|
|
.lean();
|
|
|
|
const chartsData = {
|
|
attendance: {
|
|
present: presentCount,
|
|
absent: absentCount,
|
|
late: lateCount,
|
|
excused: excusedCount,
|
|
total: totalRecordedAttendance,
|
|
attendanceRate
|
|
},
|
|
paymentsBreakdown: [
|
|
{ status: 'paid', count: paymentStatusMap.paid.count, amount: paymentStatusMap.paid.amount },
|
|
{ status: 'partial', count: paymentStatusMap.partial.count, amount: paymentStatusMap.partial.amount },
|
|
{ status: 'pending', count: paymentStatusMap.pending.count, amount: paymentStatusMap.pending.amount },
|
|
{ status: 'overdue', count: paymentStatusMap.overdue.count, amount: paymentStatusMap.overdue.amount }
|
|
],
|
|
financialSummary,
|
|
monthlyTransactions
|
|
};
|
|
|
|
return {
|
|
user: {
|
|
...user,
|
|
nationalId: user.nationalIdCode,
|
|
registeredClassesCount: classes.length
|
|
},
|
|
classes,
|
|
sessions,
|
|
attendances: userAttendances,
|
|
payments,
|
|
transactions,
|
|
financialSummary,
|
|
attendanceSummary: chartsData.attendance,
|
|
chartsData,
|
|
certificates,
|
|
documents,
|
|
waitlist,
|
|
notifications,
|
|
activityLogs
|
|
};
|
|
};
|
|
|
|
const promoteToProfessor = async (id, data = {}) => {
|
|
return professorService.createProfessorFromUser(id, data);
|
|
};
|
|
|
|
module.exports = {
|
|
signUp,
|
|
getUserById,
|
|
getUserFullProfile,
|
|
getAllUsers,
|
|
searchUsers,
|
|
createUserAdmin,
|
|
updateUser,
|
|
deleteUser,
|
|
resetPasswordAndSendSms,
|
|
enrollUserInCourse,
|
|
promoteToProfessor
|
|
};
|