From dca7250be0d2a77f80335f61cdcf0bb20a1c87a4 Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Mon, 24 Aug 2026 22:52:38 +0330 Subject: [PATCH] feat(users,professors): add promote student to professor and full student profile aggregation --- components/professors/professorController.js | 6 + components/professors/professorRoutes.js | 1 + components/professors/professorService.js | 109 ++++++++ .../users/studentProfileAndPromotion.test.js | 24 ++ components/users/userController.js | 10 + components/users/userRoutes.js | 2 + components/users/userService.js | 249 +++++++++++++++++- package.json | 2 +- 8 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 components/users/studentProfileAndPromotion.test.js diff --git a/components/professors/professorController.js b/components/professors/professorController.js index 8254e35..7d1dd19 100644 --- a/components/professors/professorController.js +++ b/components/professors/professorController.js @@ -9,6 +9,12 @@ exports.create = catchAsync(async (req, res, next) => { return successResponse(res, 201, 'Professor created successfully', professor); }); +exports.createFromUser = catchAsync(async (req, res, next) => { + const userId = req.body.userId || req.params.userId || req.body.id; + const result = await professorService.createProfessorFromUser(userId, req.body); + return successResponse(res, 201, 'Professor created from user successfully', result); +}); + exports.getOne = catchAsync(async (req, res, next) => { const professor = await professorService.getProfessorById(req.params.id); return successResponse(res, 200, 'Professor retrieved successfully', professor); diff --git a/components/professors/professorRoutes.js b/components/professors/professorRoutes.js index 93606b1..e45d059 100644 --- a/components/professors/professorRoutes.js +++ b/components/professors/professorRoutes.js @@ -12,6 +12,7 @@ const router = express.Router(); router.use(authMiddleware); router.post('/admin/create', perm.requires(PERMISSIONS.PROFESSORS_CREATE), validateCreateProfessor, professorController.create); +router.post('/admin/create-from-user', perm.requires(PERMISSIONS.PROFESSORS_CREATE), professorController.createFromUser); router.get('/admin/get-all', perm.requires(PERMISSIONS.PROFESSORS_READ), professorController.getAll); router.get('/admin/search', perm.requires(PERMISSIONS.PROFESSORS_SEARCH), professorController.search); router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PROFESSORS_READ), professorController.getOne); diff --git a/components/professors/professorService.js b/components/professors/professorService.js index 0e74c81..429468a 100644 --- a/components/professors/professorService.js +++ b/components/professors/professorService.js @@ -1,6 +1,8 @@ // /components/professors/professorService.js const Professor = require('./professorModel'); +const User = require('../users/userModel'); +const Role = require('../roles/roleModel'); const AppError = require('../../utils/AppError'); const eventEmitter = require('../../events/eventEmitter'); const EVENT_NAMES = require('../../constants/eventNames'); @@ -43,6 +45,112 @@ const createProfessor = async (data) => { return professor; }; +const createProfessorFromUser = async (userId, additionalData = {}) => { + if (!userId) { + throw new AppError('VALIDATION_FAILED', null, 'User ID is required'); + } + + const user = await User.findById(userId); + if (!user) { + throw new AppError('USER_NOT_FOUND'); + } + + const professorRole = await Role.findOne({ name: 'Professor' }); + if (!professorRole) { + throw new AppError('DEFAULT_ROLE_NOT_FOUND', null, 'Professor role not found'); + } + + // Update user role to Professor if not already + if (String(user.role) !== String(professorRole._id)) { + user.role = professorRole._id; + await user.save(); + } + + // Determine name and surname + let firstName = String(additionalData.name || '').trim(); + let lastName = String(additionalData.surname || '').trim(); + + if (!firstName && !lastName) { + const rawName = String(user.name || '').trim(); + const parts = rawName.split(/\s+/); + if (parts.length > 1) { + firstName = parts[0]; + lastName = parts.slice(1).join(' '); + } else { + firstName = rawName || 'استاد'; + lastName = rawName || 'استاد'; + } + } else if (!lastName && firstName) { + lastName = firstName; + } else if (!firstName && lastName) { + firstName = lastName; + } + + let nationalIdCode = String(additionalData.nationalIdCode || additionalData.nationalId || user.nationalIdCode || '').trim(); + const phoneNumber = String(additionalData.phoneNumber || additionalData.phone || user.phoneNumber || '').trim(); + + if (!nationalIdCode) { + nationalIdCode = await allocatePlaceholderNationalId(phoneNumber); + } + + const email = additionalData.email ? String(additionalData.email).trim().toLowerCase() : (user.email ? String(user.email).trim().toLowerCase() : undefined); + const cardNumber = additionalData.cardNumber ? String(additionalData.cardNumber).trim() : (user.cardNumber ? String(user.cardNumber).trim() : undefined); + const shabaNumber = additionalData.shabaNumber || additionalData.iban ? String(additionalData.shabaNumber || additionalData.iban).trim() : (user.shabaNumber ? String(user.shabaNumber).trim() : undefined); + const bio = additionalData.bio ? String(additionalData.bio).trim() : undefined; + + let expertise = []; + if (Array.isArray(additionalData.expertise)) { + expertise = additionalData.expertise.map(String).map(s => s.trim()).filter(Boolean); + } else if (typeof additionalData.expertise === 'string' && additionalData.expertise.trim()) { + expertise = additionalData.expertise.split(',').map(s => s.trim()).filter(Boolean); + } + + // Check if a Professor record already exists for this nationalIdCode or phoneNumber + let professor = await Professor.findOne({ + $or: [ + { nationalIdCode }, + { phoneNumber } + ] + }); + + if (professor) { + professor.name = firstName; + professor.surname = lastName; + if (email) professor.email = email; + if (cardNumber) professor.cardNumber = cardNumber; + if (shabaNumber) professor.shabaNumber = shabaNumber; + if (bio) professor.bio = bio; + if (expertise.length > 0) professor.expertise = expertise; + professor.isActive = true; + await professor.save(); + } else { + professor = await Professor.create({ + name: firstName, + surname: lastName, + nationalIdCode, + phoneNumber, + email, + cardNumber, + shabaNumber, + bio, + expertise, + isActive: true + }); + } + + eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, { + professorId: professor._id, + name: `${professor.name} ${professor.surname}` + }); + + const updatedUser = await User.findById(user._id) + .select('-passwordHash -refreshTokens') + .populate({ path: 'role', select: 'name permissions' }) + .lean(); + + return { professor, user: updatedUser }; +}; + const getProfessorById = async (id) => { const professor = await Professor.findById(id).populate('courses', 'title type price'); if (!professor) { @@ -117,6 +225,7 @@ const searchProfessors = async (queryParams) => { module.exports = { createProfessor, + createProfessorFromUser, getProfessorById, getAllProfessors, updateProfessor, diff --git a/components/users/studentProfileAndPromotion.test.js b/components/users/studentProfileAndPromotion.test.js new file mode 100644 index 0000000..0e082ab --- /dev/null +++ b/components/users/studentProfileAndPromotion.test.js @@ -0,0 +1,24 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const userService = require('./userService'); +const professorService = require('../professors/professorService'); + +describe('Student Profile and Professor Promotion Service Exports', () => { + it('exports getUserFullProfile and promoteToProfessor in userService', () => { + assert.equal(typeof userService.getUserFullProfile, 'function'); + assert.equal(typeof userService.promoteToProfessor, 'function'); + }); + + it('exports createProfessorFromUser in professorService', () => { + assert.equal(typeof professorService.createProfessorFromUser, 'function'); + }); + + it('throws validation error when userId is missing in createProfessorFromUser', async () => { + await assert.rejects( + () => professorService.createProfessorFromUser(null), + (err) => err.errorCode === 'VALIDATION_FAILED' || err.statusCode === 400 + ); + }); +}); diff --git a/components/users/userController.js b/components/users/userController.js index 721565b..957076e 100644 --- a/components/users/userController.js +++ b/components/users/userController.js @@ -36,6 +36,16 @@ exports.getOne = catchAsync(async (req, res, next) => { return successResponse(res, 200, 'User retrieved successfully', user); }); +exports.getFullProfile = catchAsync(async (req, res, next) => { + const profile = await userService.getUserFullProfile(req.params.id); + return successResponse(res, 200, 'User full profile retrieved successfully', profile); +}); + +exports.promoteToProfessor = catchAsync(async (req, res, next) => { + const result = await userService.promoteToProfessor(req.params.id, req.body); + return successResponse(res, 200, 'User promoted to professor successfully', result); +}); + exports.getAll = catchAsync(async (req, res, next) => { const { data, meta } = await userService.getAllUsers(req.query); return listResponse(res, 200, data, meta); diff --git a/components/users/userRoutes.js b/components/users/userRoutes.js index da83bb1..bdaceed 100644 --- a/components/users/userRoutes.js +++ b/components/users/userRoutes.js @@ -24,7 +24,9 @@ router.post('/admin/create', authMiddleware, perm.requires(PERMISSIONS.USERS_CRE router.get('/admin/get-all', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getAll); router.get('/admin/search', authMiddleware, perm.requires(PERMISSIONS.USERS_SEARCH), userController.search); router.get('/admin/get-one/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getOne); +router.get('/admin/:id/full-profile', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getFullProfile); router.put('/admin/update/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), validateUpdateUser, userController.update); +router.post('/admin/:id/promote-to-professor', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), userController.promoteToProfessor); router.post('/admin/:id/reset-password-sms', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), userController.resetPasswordAndSendSms); router.delete('/admin/delete/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_DELETE), userController.delete); router.post('/admin/:userId/enroll', authMiddleware, perm.requires(PERMISSIONS.USERS_ENROLL), validateEnroll, userController.enroll); diff --git a/components/users/userService.js b/components/users/userService.js index afdcb64..3b52248 100644 --- a/components/users/userService.js +++ b/components/users/userService.js @@ -4,6 +4,15 @@ 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'); @@ -363,14 +372,252 @@ const enrollUserInCourse = async (userId, courseId) => { 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 + enrollUserInCourse, + promoteToProfessor }; diff --git a/package.json b/package.json index f97d55e..d1533a4 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "node app.js", "dev": "nodemon app.js", "seed": "node seed.js", - "test": "node --test components/employeeTimings/employeeTiming.test.js components/settings/smsTemplateRender.test.js components/waitlist/waitlistService.test.js components/payments/bulkPayment.test.js components/classes/classSoftDelete.test.js components/settings/smsTemplates.test.js components/users/passwordReset.test.js components/dataImport/importHelpers.test.js utils/classSchedule.test.js utils/jalaliDate.test.js utils/messagingChannels.test.js utils/notifyFlags.test.js utils/passwordRules.test.js utils/paymentAmount.test.js utils/professorShare.test.js utils/financialRange.test.js" + "test": "node --test components/employeeTimings/employeeTiming.test.js components/settings/smsTemplateRender.test.js components/waitlist/waitlistService.test.js components/payments/bulkPayment.test.js components/classes/classSoftDelete.test.js components/settings/smsTemplates.test.js components/users/passwordReset.test.js components/users/studentProfileAndPromotion.test.js components/dataImport/importHelpers.test.js utils/classSchedule.test.js utils/jalaliDate.test.js utils/messagingChannels.test.js utils/notifyFlags.test.js utils/passwordRules.test.js utils/paymentAmount.test.js utils/professorShare.test.js utils/financialRange.test.js" }, "keywords": [ "express",