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:
@@ -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: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
bio: {
|
||||
@@ -29,16 +27,28 @@ const professorSchema = new mongoose.Schema({
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
strict: false,
|
||||
toJSON: { 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 () {
|
||||
if (this.user && typeof this.user === 'object' && this.user.name) {
|
||||
return this.user.name;
|
||||
}
|
||||
return undefined;
|
||||
return this._doc?.name || undefined;
|
||||
});
|
||||
|
||||
professorSchema.virtual('surname').get(function () {
|
||||
@@ -48,42 +58,42 @@ professorSchema.virtual('surname').get(function () {
|
||||
return parts.slice(1).join(' ');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
return this._doc?.surname || '';
|
||||
});
|
||||
|
||||
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 undefined;
|
||||
return this._doc?.nationalIdCode || undefined;
|
||||
});
|
||||
|
||||
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 undefined;
|
||||
return this._doc?.phoneNumber || undefined;
|
||||
});
|
||||
|
||||
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 undefined;
|
||||
return this._doc?.email || undefined;
|
||||
});
|
||||
|
||||
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 undefined;
|
||||
return this._doc?.cardNumber || undefined;
|
||||
});
|
||||
|
||||
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 undefined;
|
||||
return this._doc?.shabaNumber || undefined;
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Professor', professorSchema);
|
||||
|
||||
@@ -41,30 +41,48 @@ const formatProfessorDoc = (doc) => {
|
||||
const raw = doc.toObject ? doc.toObject({ virtuals: true }) : doc;
|
||||
const user = raw.user && typeof raw.user === 'object' ? raw.user : null;
|
||||
|
||||
const rawName = String(user?.name || raw.name || '').trim();
|
||||
const parts = rawName.split(/\s+/);
|
||||
let firstName = rawName;
|
||||
let surname = '';
|
||||
if (parts.length > 1) {
|
||||
firstName = parts[0];
|
||||
surname = parts.slice(1).join(' ');
|
||||
const rawUserName = String(user?.name || '').trim();
|
||||
const rawDocName = String(raw.name || '').trim();
|
||||
const rawSurname = String(raw.surname || '').trim();
|
||||
|
||||
let fullName = rawUserName || (rawDocName ? `${rawDocName} ${rawSurname}`.trim() : '');
|
||||
let firstName = '';
|
||||
let surname = rawSurname;
|
||||
|
||||
if (fullName) {
|
||||
const parts = fullName.split(/\s+/);
|
||||
if (parts.length > 1) {
|
||||
firstName = parts[0];
|
||||
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 {
|
||||
_id: raw._id,
|
||||
id: raw._id,
|
||||
user: user ? (user._id || user) : raw.user,
|
||||
userId: user ? (user._id || user) : raw.user,
|
||||
name: firstName,
|
||||
surname: surname || raw.surname || '',
|
||||
fullName: rawName,
|
||||
nationalIdCode: user?.nationalIdCode || raw.nationalIdCode || '',
|
||||
nationalId: user?.nationalIdCode || raw.nationalIdCode || '',
|
||||
phoneNumber: user?.phoneNumber || raw.phoneNumber || '',
|
||||
phone: user?.phoneNumber || raw.phoneNumber || '',
|
||||
email: user?.email || raw.email || '',
|
||||
cardNumber: user?.cardNumber || raw.cardNumber || '',
|
||||
shabaNumber: user?.shabaNumber || raw.shabaNumber || '',
|
||||
name: firstName || fullName || 'استاد',
|
||||
surname: surname || '',
|
||||
fullName: fullName || firstName || 'استاد',
|
||||
nationalIdCode: rawNationalId,
|
||||
nationalId: rawNationalId,
|
||||
phoneNumber: rawPhone,
|
||||
phone: rawPhone,
|
||||
email: rawEmail,
|
||||
cardNumber: rawCard,
|
||||
shabaNumber: rawShaba,
|
||||
bio: raw.bio || '',
|
||||
expertise: Array.isArray(raw.expertise) ? raw.expertise : [],
|
||||
courses: raw.courses || [],
|
||||
|
||||
@@ -57,4 +57,45 @@ test('Professor Model & User Merge Tests', async (t) => {
|
||||
assert.deepEqual(formatted.expertise, ['Web', 'Vue']);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user