feat(users,professors): add promote student to professor and full student profile aggregation
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user