fix(professors, settings): add auto-migration for legacy professors, deep populate user in class/session/course services, and fix SMS bypass number persistence
This commit is contained in:
@@ -122,6 +122,9 @@ const PORT = config.PORT || 3000;
|
|||||||
const startServer = async () => {
|
const startServer = async () => {
|
||||||
await connectDB();
|
await connectDB();
|
||||||
|
|
||||||
|
const { migrateAndLinkProfessorsToUsers } = require('./components/professors/professorMigration');
|
||||||
|
await migrateAndLinkProfessorsToUsers();
|
||||||
|
|
||||||
if (config.NODE_ENV === 'production') {
|
if (config.NODE_ENV === 'production') {
|
||||||
logger.info('Running production bootstrap (roles + SuperAdmin from env)');
|
logger.info('Running production bootstrap (roles + SuperAdmin from env)');
|
||||||
await seedDatabase({ disconnectOnComplete: false });
|
await seedDatabase({ disconnectOnComplete: false });
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const normalizeNumberOfSessions = (value) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const enrichClassForDisplay = (cls) => {
|
const enrichClassForDisplay = (cls) => {
|
||||||
|
if (!cls) return cls;
|
||||||
const tuitionFee = cls.tuitionFee || 0;
|
const tuitionFee = cls.tuitionFee || 0;
|
||||||
const discount = cls.hasDiscount ? (cls.discount || 0) : 0;
|
const discount = cls.hasDiscount ? (cls.discount || 0) : 0;
|
||||||
const finalTuitionFee = Math.max(0, tuitionFee - discount);
|
const finalTuitionFee = Math.max(0, tuitionFee - discount);
|
||||||
@@ -68,7 +69,23 @@ const enrichClassForDisplay = (cls) => {
|
|||||||
if (cls.startDate) {
|
if (cls.startDate) {
|
||||||
daysUntilStart = Math.ceil((new Date(cls.startDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
daysUntilStart = Math.ceil((new Date(cls.startDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
return { ...cls, finalTuitionFee, daysUntilStart };
|
|
||||||
|
let professor = cls.professor;
|
||||||
|
if (professor && typeof professor === 'object') {
|
||||||
|
const profUser = professor.user && typeof professor.user === 'object' ? professor.user : null;
|
||||||
|
const profName = String(profUser?.name || professor.name || '').trim();
|
||||||
|
const profPhone = String(profUser?.phoneNumber || professor.phoneNumber || '').trim();
|
||||||
|
const parts = profName.split(/\s+/);
|
||||||
|
professor = {
|
||||||
|
...professor,
|
||||||
|
name: parts[0] || profName || 'استاد',
|
||||||
|
surname: parts.length > 1 ? parts.slice(1).join(' ') : (professor.surname || ''),
|
||||||
|
fullName: profName || 'استاد',
|
||||||
|
phoneNumber: profPhone
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...cls, professor, finalTuitionFee, daysUntilStart };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAll = async (query = {}) => {
|
const getAll = async (query = {}) => {
|
||||||
@@ -83,8 +100,13 @@ const getAll = async (query = {}) => {
|
|||||||
filter.isDeleted = { $ne: true };
|
filter.isDeleted = { $ne: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.courseId) filter.course = query.courseId;
|
if (query.courseId) {
|
||||||
if (query.isActive !== undefined) filter.isActive = query.isActive === 'true';
|
filter.course = query.courseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isActive !== undefined) {
|
||||||
|
filter.isActive = query.isActive === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
const searchTerm = getSearchTerm(query);
|
const searchTerm = getSearchTerm(query);
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
@@ -95,7 +117,10 @@ const getAll = async (query = {}) => {
|
|||||||
Class.find(filter)
|
Class.find(filter)
|
||||||
.select(CLASS_LIST_FIELDS)
|
.select(CLASS_LIST_FIELDS)
|
||||||
.populate({ path: 'course', select: 'title type price sectionCount hoursPerSection' })
|
.populate({ path: 'course', select: 'title type price sectionCount hoursPerSection' })
|
||||||
.populate({ path: 'professor', select: 'name surname' })
|
.populate({
|
||||||
|
path: 'professor',
|
||||||
|
populate: { path: 'user', select: 'name phoneNumber email nationalIdCode' }
|
||||||
|
})
|
||||||
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
|
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
|
||||||
Class.countDocuments(filter)
|
Class.countDocuments(filter)
|
||||||
]);
|
]);
|
||||||
@@ -106,7 +131,10 @@ const getAll = async (query = {}) => {
|
|||||||
const getOne = async (id) => {
|
const getOne = async (id) => {
|
||||||
const cls = await Class.findById(id)
|
const cls = await Class.findById(id)
|
||||||
.populate({ path: 'course', select: 'title type price sectionCount hoursPerSection' })
|
.populate({ path: 'course', select: 'title type price sectionCount hoursPerSection' })
|
||||||
.populate({ path: 'professor', select: 'name surname phoneNumber' })
|
.populate({
|
||||||
|
path: 'professor',
|
||||||
|
populate: { path: 'user', select: 'name phoneNumber email nationalIdCode' }
|
||||||
|
})
|
||||||
.populate({ path: 'students', select: 'name phoneNumber gender' })
|
.populate({ path: 'students', select: 'name phoneNumber gender' })
|
||||||
.lean();
|
.lean();
|
||||||
if (!cls) throw new AppError('CLASS_NOT_FOUND');
|
if (!cls) throw new AppError('CLASS_NOT_FOUND');
|
||||||
@@ -247,7 +275,10 @@ const getMyClasses = async (userId, query = {}) => {
|
|||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
Class.find(filter)
|
Class.find(filter)
|
||||||
.populate({ path: 'course', select: 'title type price description' })
|
.populate({ path: 'course', select: 'title type price description' })
|
||||||
.populate({ path: 'professor', select: 'name surname' })
|
.populate({
|
||||||
|
path: 'professor',
|
||||||
|
populate: { path: 'user', select: 'name phoneNumber nationalIdCode' }
|
||||||
|
})
|
||||||
.skip(skip).limit(limit).sort({ startDate: -1, createdAt: -1 }).lean(),
|
.skip(skip).limit(limit).sort({ startDate: -1, createdAt: -1 }).lean(),
|
||||||
Class.countDocuments(filter)
|
Class.countDocuments(filter)
|
||||||
]);
|
]);
|
||||||
@@ -271,7 +302,10 @@ const getPublicClasses = async (query = {}) => {
|
|||||||
Class.find(filter)
|
Class.find(filter)
|
||||||
.select('name course professor capacity tuitionFee hasDiscount discount freeSpots startDate endDate days startTime endTime')
|
.select('name course professor capacity tuitionFee hasDiscount discount freeSpots startDate endDate days startTime endTime')
|
||||||
.populate({ path: 'course', select: 'title type description' })
|
.populate({ path: 'course', select: 'title type description' })
|
||||||
.populate({ path: 'professor', select: 'name surname' })
|
.populate({
|
||||||
|
path: 'professor',
|
||||||
|
populate: { path: 'user', select: 'name phoneNumber' }
|
||||||
|
})
|
||||||
.skip(skip).limit(limit).sort({ startDate: 1, createdAt: -1 }).lean(),
|
.skip(skip).limit(limit).sort({ startDate: 1, createdAt: -1 }).lean(),
|
||||||
Class.countDocuments(filter)
|
Class.countDocuments(filter)
|
||||||
]);
|
]);
|
||||||
@@ -288,7 +322,10 @@ const getPublicOne = async (id) => {
|
|||||||
})
|
})
|
||||||
.select('name course professor capacity tuitionFee hasDiscount discount freeSpots startDate endDate days startTime endTime isActive')
|
.select('name course professor capacity tuitionFee hasDiscount discount freeSpots startDate endDate days startTime endTime isActive')
|
||||||
.populate({ path: 'course', select: 'title type description sectionCount hoursPerSection price' })
|
.populate({ path: 'course', select: 'title type description sectionCount hoursPerSection price' })
|
||||||
.populate({ path: 'professor', select: 'name surname' })
|
.populate({
|
||||||
|
path: 'professor',
|
||||||
|
populate: { path: 'user', select: 'name phoneNumber' }
|
||||||
|
})
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
if (!cls) throw new AppError('CLASS_NOT_FOUND', null, 'کلاس یافت نشد یا برای ثبتنام در دسترس نیست.');
|
if (!cls) throw new AppError('CLASS_NOT_FOUND', null, 'کلاس یافت نشد یا برای ثبتنام در دسترس نیست.');
|
||||||
@@ -298,7 +335,10 @@ const getPublicOne = async (id) => {
|
|||||||
const sendClassPlanToProfessor = async (classId) => {
|
const sendClassPlanToProfessor = async (classId) => {
|
||||||
const classDoc = await Class.findById(classId)
|
const classDoc = await Class.findById(classId)
|
||||||
.populate('course', 'title')
|
.populate('course', 'title')
|
||||||
.populate('professor', 'name surname phoneNumber');
|
.populate({
|
||||||
|
path: 'professor',
|
||||||
|
populate: { path: 'user', select: 'name phoneNumber email' }
|
||||||
|
});
|
||||||
|
|
||||||
if (!classDoc) {
|
if (!classDoc) {
|
||||||
throw new AppError('CLASS_NOT_FOUND', null, 'کلاس یافت نشد.');
|
throw new AppError('CLASS_NOT_FOUND', null, 'کلاس یافت نشد.');
|
||||||
@@ -309,11 +349,13 @@ const sendClassPlanToProfessor = async (classId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const professor = classDoc.professor;
|
const professor = classDoc.professor;
|
||||||
if (!professor.phoneNumber) {
|
const profUser = professor.user && typeof professor.user === 'object' ? professor.user : null;
|
||||||
|
const phoneNumber = profUser?.phoneNumber || professor.phoneNumber;
|
||||||
|
if (!phoneNumber) {
|
||||||
throw new AppError('VALIDATION_FAILED', { field: 'phoneNumber' }, 'شماره همراه استاد ثبت نشده است.');
|
throw new AppError('VALIDATION_FAILED', { field: 'phoneNumber' }, 'شماره همراه استاد ثبت نشده است.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const profName = `${professor.name || ''} ${professor.surname || ''}`.trim() || 'استاد';
|
const profName = profUser?.name || `${professor.name || ''} ${professor.surname || ''}`.trim() || 'استاد';
|
||||||
const className = classDoc.name || classDoc.course?.title || 'کلاس';
|
const className = classDoc.name || classDoc.course?.title || 'کلاس';
|
||||||
const classDays = formatClassDaysFromIndexes(classDoc.days) || 'طبق هماهنگی';
|
const classDays = formatClassDaysFromIndexes(classDoc.days) || 'طبق هماهنگی';
|
||||||
const classTimes = (classDoc.startTime && classDoc.endTime)
|
const classTimes = (classDoc.startTime && classDoc.endTime)
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ const getCourseById = async (id, { publicOnly = false } = {}) => {
|
|||||||
const filter = { _id: id };
|
const filter = { _id: id };
|
||||||
if (publicOnly) filter.showOnFrontend = { $ne: false };
|
if (publicOnly) filter.showOnFrontend = { $ne: false };
|
||||||
|
|
||||||
const course = await Course.findOne(filter).populate('professor', 'name surname title expertise email phoneNumber');
|
const course = await Course.findOne(filter).populate({
|
||||||
|
path: 'professor',
|
||||||
|
populate: { path: 'user', select: 'name surname title expertise email phoneNumber' }
|
||||||
|
});
|
||||||
if (!course) {
|
if (!course) {
|
||||||
throw new AppError('COURSE_NOT_FOUND');
|
throw new AppError('COURSE_NOT_FOUND');
|
||||||
}
|
}
|
||||||
@@ -69,7 +72,14 @@ const getAllCourses = async (queryParams, { publicOnly = false } = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [courses, totalCount] = await Promise.all([
|
const [courses, totalCount] = await Promise.all([
|
||||||
Course.find(filter).populate('professor', 'name surname').sort(finalSort).skip(skip).limit(limit),
|
Course.find(filter)
|
||||||
|
.populate({
|
||||||
|
path: 'professor',
|
||||||
|
populate: { path: 'user', select: 'name surname' }
|
||||||
|
})
|
||||||
|
.sort(finalSort)
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit),
|
||||||
Course.countDocuments(filter)
|
Course.countDocuments(filter)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ const getAdminStats = async () => {
|
|||||||
.limit(8)
|
.limit(8)
|
||||||
.populate('course', 'title type')
|
.populate('course', 'title type')
|
||||||
.populate('class', 'name')
|
.populate('class', 'name')
|
||||||
.populate('professor', 'name surname')
|
.populate({
|
||||||
|
path: 'professor',
|
||||||
|
populate: { path: 'user', select: 'name surname' }
|
||||||
|
})
|
||||||
.lean()
|
.lean()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// /components/professors/professorMigration.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const Professor = require('./professorModel');
|
||||||
|
const User = require('../users/userModel');
|
||||||
|
const Role = require('../roles/roleModel');
|
||||||
|
const logger = require('../../utils/logger');
|
||||||
|
const { allocatePlaceholderNationalId } = require('../../utils/nationalId');
|
||||||
|
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const ensureProfessorRole = async () => {
|
||||||
|
let role = await Role.findOne({ name: 'Professor' });
|
||||||
|
if (!role) {
|
||||||
|
role = await Role.create({
|
||||||
|
name: 'Professor',
|
||||||
|
description: 'استاد و مدرس دورهها',
|
||||||
|
permissions: ['professors:read', 'classes:read', 'sessions:read', 'attendances:read'],
|
||||||
|
isSystem: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically migrate and link all legacy Professor records to the Users table.
|
||||||
|
* Ensures every Professor document has a valid, populated `user` reference.
|
||||||
|
*/
|
||||||
|
const migrateAndLinkProfessorsToUsers = async () => {
|
||||||
|
try {
|
||||||
|
const professorRole = await ensureProfessorRole();
|
||||||
|
const professors = await Professor.find({}).setOptions({ _skipUserPopulate: true });
|
||||||
|
|
||||||
|
if (!professors.length) {
|
||||||
|
logger.info('Professor migration: No professor records found.');
|
||||||
|
return { total: 0, migrated: 0, alreadyLinked: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let migratedCount = 0;
|
||||||
|
let alreadyLinkedCount = 0;
|
||||||
|
|
||||||
|
for (const prof of professors) {
|
||||||
|
const rawDoc = prof._doc || prof;
|
||||||
|
let linkedUser = null;
|
||||||
|
|
||||||
|
// 1. Check if professor already has a valid user reference
|
||||||
|
if (prof.user && mongoose.Types.ObjectId.isValid(prof.user)) {
|
||||||
|
linkedUser = await User.findById(prof.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkedUser) {
|
||||||
|
// Ensure user role is Professor if not an admin
|
||||||
|
if (!linkedUser.role || (String(linkedUser.role) !== String(professorRole._id) && linkedUser.role?.name !== 'SuperAdmin' && linkedUser.role?.name !== 'Admin')) {
|
||||||
|
linkedUser.role = professorRole._id;
|
||||||
|
await linkedUser.save();
|
||||||
|
}
|
||||||
|
alreadyLinkedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Professor is not linked to a user. Find matching user by phone or national ID
|
||||||
|
const rawPhone = String(rawDoc.phoneNumber || rawDoc.phone || '').trim();
|
||||||
|
const rawNationalId = String(rawDoc.nationalIdCode || rawDoc.nationalId || '').trim();
|
||||||
|
const rawName = String(rawDoc.name || '').trim();
|
||||||
|
const rawSurname = String(rawDoc.surname || '').trim();
|
||||||
|
const fullName = [rawName, rawSurname].filter(Boolean).join(' ').trim() || 'استاد بدون نام';
|
||||||
|
|
||||||
|
if (rawPhone) {
|
||||||
|
linkedUser = await User.findOne({ phoneNumber: rawPhone });
|
||||||
|
}
|
||||||
|
if (!linkedUser && rawNationalId) {
|
||||||
|
linkedUser = await User.findOne({ nationalIdCode: rawNationalId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If matching user found, link and update role
|
||||||
|
if (linkedUser) {
|
||||||
|
if (!linkedUser.role || (String(linkedUser.role) !== String(professorRole._id) && linkedUser.role?.name !== 'SuperAdmin' && linkedUser.role?.name !== 'Admin')) {
|
||||||
|
linkedUser.role = professorRole._id;
|
||||||
|
}
|
||||||
|
if (!linkedUser.name && fullName) {
|
||||||
|
linkedUser.name = fullName;
|
||||||
|
}
|
||||||
|
if (!linkedUser.cardNumber && rawDoc.cardNumber) {
|
||||||
|
linkedUser.cardNumber = String(rawDoc.cardNumber).trim();
|
||||||
|
}
|
||||||
|
if (!linkedUser.shabaNumber && (rawDoc.shabaNumber || rawDoc.iban)) {
|
||||||
|
linkedUser.shabaNumber = String(rawDoc.shabaNumber || rawDoc.iban).trim();
|
||||||
|
}
|
||||||
|
await linkedUser.save();
|
||||||
|
|
||||||
|
prof.user = linkedUser._id;
|
||||||
|
await prof.save();
|
||||||
|
migratedCount++;
|
||||||
|
logger.info(`Professor migration: Linked professor "${fullName}" (${prof._id}) to existing user (${linkedUser._id}).`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. If no matching user found, create a new User for this professor
|
||||||
|
let nationalIdCode = rawNationalId;
|
||||||
|
if (!nationalIdCode) {
|
||||||
|
nationalIdCode = await allocatePlaceholderNationalId(rawPhone || fullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedUsername = await generateUsername(fullName);
|
||||||
|
const plainPassword = generateSimplePassword();
|
||||||
|
const passwordHash = await bcrypt.hash(plainPassword, 10);
|
||||||
|
|
||||||
|
const newUser = await User.create({
|
||||||
|
name: fullName,
|
||||||
|
nationalIdCode,
|
||||||
|
phoneNumber: rawPhone || undefined,
|
||||||
|
email: rawDoc.email ? String(rawDoc.email).trim().toLowerCase() : undefined,
|
||||||
|
role: professorRole._id,
|
||||||
|
username: generatedUsername,
|
||||||
|
passwordHash,
|
||||||
|
cardNumber: rawDoc.cardNumber ? String(rawDoc.cardNumber).trim() : undefined,
|
||||||
|
shabaNumber: (rawDoc.shabaNumber || rawDoc.iban) ? String(rawDoc.shabaNumber || rawDoc.iban).trim() : undefined,
|
||||||
|
isActive: prof.isActive !== false
|
||||||
|
});
|
||||||
|
|
||||||
|
prof.user = newUser._id;
|
||||||
|
await prof.save();
|
||||||
|
migratedCount++;
|
||||||
|
logger.info(`Professor migration: Created new user (${newUser._id}) for legacy professor "${fullName}" (${prof._id}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Professor migration complete: ${professors.length} total, ${migratedCount} newly linked/created, ${alreadyLinkedCount} already linked.`);
|
||||||
|
return { total: professors.length, migrated: migratedCount, alreadyLinked: alreadyLinkedCount };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during migrateAndLinkProfessorsToUsers:', error);
|
||||||
|
return { error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
migrateAndLinkProfessorsToUsers,
|
||||||
|
ensureProfessorRole
|
||||||
|
};
|
||||||
@@ -7,8 +7,6 @@ const professorSchema = new mongoose.Schema({
|
|||||||
user: {
|
user: {
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'User',
|
ref: 'User',
|
||||||
required: true,
|
|
||||||
unique: true,
|
|
||||||
index: true
|
index: true
|
||||||
},
|
},
|
||||||
bio: {
|
bio: {
|
||||||
@@ -29,16 +27,28 @@ const professorSchema = new mongoose.Schema({
|
|||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
|
strict: false,
|
||||||
toJSON: { virtuals: true },
|
toJSON: { virtuals: true },
|
||||||
toObject: { virtuals: true }
|
toObject: { virtuals: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Virtual properties delegating personal details to the linked User model
|
// Auto-populate linked user document on find queries unless explicitly skipped
|
||||||
|
professorSchema.pre(/^find/, function (next) {
|
||||||
|
if (this.options?._skipUserPopulate !== true) {
|
||||||
|
this.populate({
|
||||||
|
path: 'user',
|
||||||
|
select: 'name nationalIdCode phoneNumber email cardNumber shabaNumber role isActive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Virtual properties delegating personal details to the linked User model with legacy fallback
|
||||||
professorSchema.virtual('name').get(function () {
|
professorSchema.virtual('name').get(function () {
|
||||||
if (this.user && typeof this.user === 'object' && this.user.name) {
|
if (this.user && typeof this.user === 'object' && this.user.name) {
|
||||||
return this.user.name;
|
return this.user.name;
|
||||||
}
|
}
|
||||||
return undefined;
|
return this._doc?.name || undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
professorSchema.virtual('surname').get(function () {
|
professorSchema.virtual('surname').get(function () {
|
||||||
@@ -48,42 +58,42 @@ professorSchema.virtual('surname').get(function () {
|
|||||||
return parts.slice(1).join(' ');
|
return parts.slice(1).join(' ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '';
|
return this._doc?.surname || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
professorSchema.virtual('nationalIdCode').get(function () {
|
professorSchema.virtual('nationalIdCode').get(function () {
|
||||||
if (this.user && typeof this.user === 'object') {
|
if (this.user && typeof this.user === 'object' && this.user.nationalIdCode) {
|
||||||
return this.user.nationalIdCode;
|
return this.user.nationalIdCode;
|
||||||
}
|
}
|
||||||
return undefined;
|
return this._doc?.nationalIdCode || undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
professorSchema.virtual('phoneNumber').get(function () {
|
professorSchema.virtual('phoneNumber').get(function () {
|
||||||
if (this.user && typeof this.user === 'object') {
|
if (this.user && typeof this.user === 'object' && this.user.phoneNumber) {
|
||||||
return this.user.phoneNumber;
|
return this.user.phoneNumber;
|
||||||
}
|
}
|
||||||
return undefined;
|
return this._doc?.phoneNumber || undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
professorSchema.virtual('email').get(function () {
|
professorSchema.virtual('email').get(function () {
|
||||||
if (this.user && typeof this.user === 'object') {
|
if (this.user && typeof this.user === 'object' && this.user.email) {
|
||||||
return this.user.email;
|
return this.user.email;
|
||||||
}
|
}
|
||||||
return undefined;
|
return this._doc?.email || undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
professorSchema.virtual('cardNumber').get(function () {
|
professorSchema.virtual('cardNumber').get(function () {
|
||||||
if (this.user && typeof this.user === 'object') {
|
if (this.user && typeof this.user === 'object' && this.user.cardNumber) {
|
||||||
return this.user.cardNumber;
|
return this.user.cardNumber;
|
||||||
}
|
}
|
||||||
return undefined;
|
return this._doc?.cardNumber || undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
professorSchema.virtual('shabaNumber').get(function () {
|
professorSchema.virtual('shabaNumber').get(function () {
|
||||||
if (this.user && typeof this.user === 'object') {
|
if (this.user && typeof this.user === 'object' && this.user.shabaNumber) {
|
||||||
return this.user.shabaNumber;
|
return this.user.shabaNumber;
|
||||||
}
|
}
|
||||||
return undefined;
|
return this._doc?.shabaNumber || undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = mongoose.model('Professor', professorSchema);
|
module.exports = mongoose.model('Professor', professorSchema);
|
||||||
|
|||||||
@@ -41,30 +41,48 @@ const formatProfessorDoc = (doc) => {
|
|||||||
const raw = doc.toObject ? doc.toObject({ virtuals: true }) : doc;
|
const raw = doc.toObject ? doc.toObject({ virtuals: true }) : doc;
|
||||||
const user = raw.user && typeof raw.user === 'object' ? raw.user : null;
|
const user = raw.user && typeof raw.user === 'object' ? raw.user : null;
|
||||||
|
|
||||||
const rawName = String(user?.name || raw.name || '').trim();
|
const rawUserName = String(user?.name || '').trim();
|
||||||
const parts = rawName.split(/\s+/);
|
const rawDocName = String(raw.name || '').trim();
|
||||||
let firstName = rawName;
|
const rawSurname = String(raw.surname || '').trim();
|
||||||
let surname = '';
|
|
||||||
|
let fullName = rawUserName || (rawDocName ? `${rawDocName} ${rawSurname}`.trim() : '');
|
||||||
|
let firstName = '';
|
||||||
|
let surname = rawSurname;
|
||||||
|
|
||||||
|
if (fullName) {
|
||||||
|
const parts = fullName.split(/\s+/);
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
firstName = parts[0];
|
firstName = parts[0];
|
||||||
surname = parts.slice(1).join(' ');
|
surname = parts.slice(1).join(' ');
|
||||||
|
} else {
|
||||||
|
firstName = fullName;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
firstName = rawDocName;
|
||||||
|
fullName = `${firstName} ${surname}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPhone = String(user?.phoneNumber || raw.phoneNumber || raw.phone || '').trim();
|
||||||
|
const rawNationalId = String(user?.nationalIdCode || raw.nationalIdCode || raw.nationalId || '').trim();
|
||||||
|
const rawEmail = String(user?.email || raw.email || '').trim();
|
||||||
|
const rawCard = String(user?.cardNumber || raw.cardNumber || '').trim();
|
||||||
|
const rawShaba = String(user?.shabaNumber || raw.shabaNumber || raw.iban || '').trim();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_id: raw._id,
|
_id: raw._id,
|
||||||
id: raw._id,
|
id: raw._id,
|
||||||
user: user ? (user._id || user) : raw.user,
|
user: user ? (user._id || user) : raw.user,
|
||||||
userId: user ? (user._id || user) : raw.user,
|
userId: user ? (user._id || user) : raw.user,
|
||||||
name: firstName,
|
name: firstName || fullName || 'استاد',
|
||||||
surname: surname || raw.surname || '',
|
surname: surname || '',
|
||||||
fullName: rawName,
|
fullName: fullName || firstName || 'استاد',
|
||||||
nationalIdCode: user?.nationalIdCode || raw.nationalIdCode || '',
|
nationalIdCode: rawNationalId,
|
||||||
nationalId: user?.nationalIdCode || raw.nationalIdCode || '',
|
nationalId: rawNationalId,
|
||||||
phoneNumber: user?.phoneNumber || raw.phoneNumber || '',
|
phoneNumber: rawPhone,
|
||||||
phone: user?.phoneNumber || raw.phoneNumber || '',
|
phone: rawPhone,
|
||||||
email: user?.email || raw.email || '',
|
email: rawEmail,
|
||||||
cardNumber: user?.cardNumber || raw.cardNumber || '',
|
cardNumber: rawCard,
|
||||||
shabaNumber: user?.shabaNumber || raw.shabaNumber || '',
|
shabaNumber: rawShaba,
|
||||||
bio: raw.bio || '',
|
bio: raw.bio || '',
|
||||||
expertise: Array.isArray(raw.expertise) ? raw.expertise : [],
|
expertise: Array.isArray(raw.expertise) ? raw.expertise : [],
|
||||||
courses: raw.courses || [],
|
courses: raw.courses || [],
|
||||||
|
|||||||
@@ -57,4 +57,45 @@ test('Professor Model & User Merge Tests', async (t) => {
|
|||||||
assert.deepEqual(formatted.expertise, ['Web', 'Vue']);
|
assert.deepEqual(formatted.expertise, ['Web', 'Vue']);
|
||||||
assert.equal(formatted.isActive, true);
|
assert.equal(formatted.isActive, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await t.test('formatProfessorDoc handles legacy documents without linked user', () => {
|
||||||
|
const profId = new mongoose.Types.ObjectId();
|
||||||
|
const legacyDoc = {
|
||||||
|
_id: profId,
|
||||||
|
name: 'رضا',
|
||||||
|
surname: 'اکبری',
|
||||||
|
phoneNumber: '09351234567',
|
||||||
|
nationalIdCode: '1234567890',
|
||||||
|
email: 'reza@example.com',
|
||||||
|
cardNumber: '5022291012345678',
|
||||||
|
shabaNumber: 'IR980000000000000000000000',
|
||||||
|
bio: 'مدرس پایگاه داده',
|
||||||
|
isActive: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatted = formatProfessorDoc(legacyDoc);
|
||||||
|
assert.equal(String(formatted._id), String(profId));
|
||||||
|
assert.equal(formatted.name, 'رضا');
|
||||||
|
assert.equal(formatted.surname, 'اکبری');
|
||||||
|
assert.equal(formatted.fullName, 'رضا اکبری');
|
||||||
|
assert.equal(formatted.phoneNumber, '09351234567');
|
||||||
|
assert.equal(formatted.nationalIdCode, '1234567890');
|
||||||
|
assert.equal(formatted.email, 'reza@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('Professor virtuals fallback correctly on legacy doc', () => {
|
||||||
|
const prof = new Professor();
|
||||||
|
prof._doc = {
|
||||||
|
name: 'مریم احمدی',
|
||||||
|
surname: 'احمدی',
|
||||||
|
phoneNumber: '09121112233',
|
||||||
|
nationalIdCode: '0076543210',
|
||||||
|
email: 'maryam@example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(prof.name, 'مریم احمدی');
|
||||||
|
assert.equal(prof.surname, 'احمدی');
|
||||||
|
assert.equal(prof.phoneNumber, '09121112233');
|
||||||
|
assert.equal(prof.nationalIdCode, '0076543210');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,7 +91,10 @@ const getSessionById = async (id) => {
|
|||||||
select: 'name students capacity',
|
select: 'name students capacity',
|
||||||
populate: { path: 'students', select: 'name nationalIdCode phoneNumber gender' }
|
populate: { path: 'students', select: 'name nationalIdCode phoneNumber gender' }
|
||||||
})
|
})
|
||||||
.populate('professor', 'name surname email phoneNumber')
|
.populate({
|
||||||
|
path: 'professor',
|
||||||
|
populate: { path: 'user', select: 'name surname email phoneNumber' }
|
||||||
|
})
|
||||||
.populate('attendanceList.user', 'name username nationalIdCode');
|
.populate('attendanceList.user', 'name username nationalIdCode');
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new AppError('SESSION_NOT_FOUND');
|
throw new AppError('SESSION_NOT_FOUND');
|
||||||
@@ -232,7 +235,10 @@ const populateSessionList = (query, { includeClassStudents = false } = {}) => {
|
|||||||
let chain = query
|
let chain = query
|
||||||
.populate('course', 'title type')
|
.populate('course', 'title type')
|
||||||
.populate('class', includeClassStudents ? 'name students' : 'name')
|
.populate('class', includeClassStudents ? 'name students' : 'name')
|
||||||
.populate('professor', 'name surname');
|
.populate({
|
||||||
|
path: 'professor',
|
||||||
|
populate: { path: 'user', select: 'name surname phoneNumber' }
|
||||||
|
});
|
||||||
return chain;
|
return chain;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -86,15 +86,16 @@ const toPublicTemplates = (storedMap, notificationMap = {}) => {
|
|||||||
const formatBypassNumbers = (list) => {
|
const formatBypassNumbers = (list) => {
|
||||||
if (!Array.isArray(list)) return [];
|
if (!Array.isArray(list)) return [];
|
||||||
return list.map((item) => {
|
return list.map((item) => {
|
||||||
const raw = item.toObject ? item.toObject() : item;
|
const raw = item && item.toObject ? item.toObject() : item;
|
||||||
|
if (!raw) return null;
|
||||||
return {
|
return {
|
||||||
_id: raw._id,
|
_id: raw._id ? String(raw._id) : undefined,
|
||||||
phoneNumber: raw.phoneNumber,
|
phoneNumber: raw.phoneNumber || '',
|
||||||
label: raw.label || '',
|
label: raw.label || '',
|
||||||
isActive: raw.isActive !== false,
|
isActive: raw.isActive !== false,
|
||||||
createdAt: raw.createdAt
|
createdAt: raw.createdAt
|
||||||
};
|
};
|
||||||
});
|
}).filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSettings = async () => {
|
const getSettings = async () => {
|
||||||
@@ -273,17 +274,20 @@ const saveSettings = async (body = {}) => {
|
|||||||
.map((item) => {
|
.map((item) => {
|
||||||
const rawPhone = item.phoneNumber || item.phone;
|
const rawPhone = item.phoneNumber || item.phone;
|
||||||
const normalized = normalizePhoneForBypass(rawPhone);
|
const normalized = normalizePhoneForBypass(rawPhone);
|
||||||
|
const validId = (item._id && mongoose.Types.ObjectId.isValid(String(item._id)))
|
||||||
|
? new mongoose.Types.ObjectId(String(item._id))
|
||||||
|
: new mongoose.Types.ObjectId();
|
||||||
return {
|
return {
|
||||||
_id: item._id || undefined,
|
_id: validId,
|
||||||
phoneNumber: normalized || rawPhone,
|
phoneNumber: normalized || String(rawPhone || '').trim(),
|
||||||
label: String(item.label || '').trim(),
|
label: String(item.label || '').trim(),
|
||||||
isActive: item.isActive !== false,
|
isActive: item.isActive !== false,
|
||||||
createdAt: item.createdAt || new Date()
|
createdAt: item.createdAt ? new Date(item.createdAt) : new Date()
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((item) => item.phoneNumber && item.phoneNumber.length >= 10);
|
.filter((item) => item.phoneNumber && item.phoneNumber.length >= 10);
|
||||||
|
|
||||||
doc.set('smsBypassNumbers', nextBypass);
|
doc.smsBypassNumbers = nextBypass;
|
||||||
doc.markModified('smsBypassNumbers');
|
doc.markModified('smsBypassNumbers');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
const { normalizePhoneForBypass } = require('./messagingFlags');
|
const { normalizePhoneForBypass } = require('./messagingFlags');
|
||||||
|
|
||||||
test('SMS Bypass Numbers Utilities', async (t) => {
|
test('SMS Bypass Numbers Utilities', async (t) => {
|
||||||
|
|||||||
@@ -204,6 +204,9 @@ const seedDatabase = async ({ disconnectOnComplete = false } = {}) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { migrateAndLinkProfessorsToUsers } = require('./components/professors/professorMigration');
|
||||||
|
await migrateAndLinkProfessorsToUsers();
|
||||||
|
|
||||||
console.log('Database seeding completed successfully.');
|
console.log('Database seeding completed successfully.');
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user