feat: add professor import support, user account creation, and IBAN/card fields

This commit is contained in:
2026-08-21 12:56:15 +03:30
parent 22b57eeae2
commit 7c74950c95
8 changed files with 344 additions and 9 deletions
+140 -5
View File
@@ -5,6 +5,7 @@ const bcrypt = require('bcryptjs');
const Course = require('../courses/courseModel');
const Class = require('../classes/classModel');
const User = require('../users/userModel');
const Professor = require('../professors/professorModel');
const Role = require('../roles/roleModel');
const Session = require('../sessions/sessionModel');
const Payment = require('../payments/paymentModel');
@@ -13,7 +14,7 @@ const paymentService = require('../payments/paymentService');
const AppError = require('../../utils/AppError');
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
const { parseImportDate, utcDayRange } = require('../../utils/jalaliDate');
const { parseImportDate, utcDayRange, toEnglishDigits } = require('../../utils/jalaliDate');
const {
namesMatch,
normalizePersonName,
@@ -38,13 +39,25 @@ const allocatePlaceholderNationalId = async (phoneNumber) => {
const base = `TMP${(phoneNumber || '').replace(/\D/g, '').slice(-10) || Date.now().toString().slice(-10)}`;
let candidate = base.padEnd(10, '0').slice(0, 10);
let i = 0;
while (await User.exists({ nationalIdCode: candidate })) {
while ((await User.exists({ nationalIdCode: candidate })) || (await Professor.exists({ nationalIdCode: candidate }))) {
i += 1;
candidate = `${base.slice(0, 7)}${String(i).padStart(3, '0')}`.slice(0, 10);
}
return candidate;
};
const allocatePlaceholderPhone = async (seed) => {
const baseDigits = (seed || Date.now().toString()).replace(/\D/g, '');
const suffix = baseDigits.slice(-7).padStart(7, '0');
let candidate = `0999${suffix}`.slice(0, 11);
let i = 0;
while ((await User.exists({ phoneNumber: candidate })) || (await Professor.exists({ phoneNumber: candidate }))) {
i += 1;
candidate = `0999${String(i).padStart(7, '0')}`.slice(0, 11);
}
return candidate;
};
const findExistingUser = async ({ nationalIdCode, phoneNumber, name }) => {
if (nationalIdCode) {
const byId = await User.findOne({ nationalIdCode });
@@ -379,18 +392,134 @@ const migrateEmbeddedPaymentTransactions = async () => {
}
};
const upsertProfessorAndUser = async (profInput, professorRole, userRole, stats, warnings) => {
const name = normalizePersonName(profInput.name);
const surname = normalizePersonName(profInput.surname);
const fullName = mergeFullName(name, surname);
let phoneNumber = normalizePhone(profInput.phoneNumber || profInput.phone);
let nationalIdCode = normalizeNationalId(profInput.nationalIdCode || profInput.nationalId);
const cardNumber = profInput.cardNumber ? String(profInput.cardNumber).replace(/\D/g, '').trim() : undefined;
const shabaNumber = (profInput.shabaNumber || profInput.iban)
? String(profInput.shabaNumber || profInput.iban).replace(/[^0-9A-Za-z]/g, '').trim()
: undefined;
const email = profInput.email ? String(profInput.email).trim().toLowerCase() : undefined;
if (!fullName && !phoneNumber && !nationalIdCode) {
warnings.push({ reason: 'missing_identity', professor: profInput });
stats.professorsSkipped += 1;
return null;
}
if (!nationalIdCode) {
nationalIdCode = await allocatePlaceholderNationalId(phoneNumber || name);
warnings.push({
reason: 'placeholder_national_id',
professor: { name: fullName, phoneNumber, nationalIdCode }
});
}
if (!phoneNumber) {
phoneNumber = await allocatePlaceholderPhone(nationalIdCode || name);
warnings.push({
reason: 'placeholder_phone',
professor: { name: fullName, phoneNumber, nationalIdCode }
});
}
let existingProf = null;
if (nationalIdCode) {
existingProf = await Professor.findOne({ nationalIdCode });
}
if (!existingProf && phoneNumber) {
existingProf = await Professor.findOne({ phoneNumber });
}
if (!existingProf && fullName) {
const allProfs = await Professor.find({});
existingProf = allProfs.find((p) => namesMatch(`${p.name} ${p.surname}`, fullName) || namesMatch(p.name, name));
}
let professorDoc;
if (existingProf) {
if (name) existingProf.name = name;
if (surname) existingProf.surname = surname;
if (cardNumber) existingProf.cardNumber = cardNumber;
if (shabaNumber) existingProf.shabaNumber = shabaNumber;
if (email && !existingProf.email) existingProf.email = email;
if (profInput.bio && !existingProf.bio) existingProf.bio = profInput.bio;
await existingProf.save();
professorDoc = existingProf;
stats.professorsUpdated += 1;
} else {
professorDoc = await Professor.create({
name: name || fullName,
surname: surname || '',
nationalIdCode,
phoneNumber,
cardNumber: cardNumber || undefined,
shabaNumber: shabaNumber || undefined,
email: email || undefined,
bio: profInput.bio || undefined,
isActive: true
});
stats.professorsCreated += 1;
}
const targetRole = professorRole || userRole;
let user = await findExistingUser({ nationalIdCode, phoneNumber, name: fullName });
if (user) {
if (fullName && !user.name) user.name = fullName;
if (cardNumber && !user.cardNumber) user.cardNumber = cardNumber;
if (shabaNumber && !user.shabaNumber) user.shabaNumber = shabaNumber;
if (email && !user.email) user.email = email;
if (targetRole && String(user.role) === String(userRole?._id)) {
user.role = targetRole._id;
}
await user.save();
stats.studentsUpdated += 1;
} else {
const username = await allocateUniqueUsername();
const plainPassword = generateSimplePassword();
const passwordHash = await bcrypt.hash(plainPassword, 10);
user = await User.create({
name: fullName || `استاد ${phoneNumber}`,
nationalIdCode,
phoneNumber,
cardNumber: cardNumber || undefined,
shabaNumber: shabaNumber || undefined,
email: email || undefined,
username,
passwordHash,
role: targetRole._id,
preferredMessenger: ['SMS'],
isActive: true
});
stats.studentsCreated += 1;
}
return { professor: professorDoc, user };
};
const importData = async (rawPayload) => {
const payload = normalizeImportPayload(rawPayload);
if (!payload || !Array.isArray(payload.courses)) {
throw new AppError('VALIDATION_FAILED', null, 'Invalid import payload: courses array required');
if (!payload || (!Array.isArray(payload.courses) && !Array.isArray(payload.professors))) {
throw new AppError('VALIDATION_FAILED', null, 'Invalid import payload: courses or professors array required');
}
const userRole = await Role.findOne({ name: 'User' });
if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
let professorRole = await Role.findOne({ name: 'Professor' });
if (!professorRole) {
professorRole = userRole;
}
await migrateEmbeddedPaymentTransactions();
const stats = {
professorsCreated: 0,
professorsUpdated: 0,
professorsSkipped: 0,
coursesCreated: 0,
coursesReused: 0,
classesCreated: 0,
@@ -411,7 +540,13 @@ const importData = async (rawPayload) => {
};
const warnings = [];
for (const courseInput of payload.courses) {
const professors = Array.isArray(payload.professors) ? payload.professors : [];
for (const profInput of professors) {
await upsertProfessorAndUser(profInput, professorRole, userRole, stats, warnings);
}
const courses = Array.isArray(payload.courses) ? payload.courses : [];
for (const courseInput of courses) {
const title = String(courseInput.title || '').trim();
if (!title) continue;
+9 -3
View File
@@ -2,7 +2,7 @@
const { toEnglishDigits } = require('../../utils/jalaliDate');
const TITLE_PREFIX = /^(آقای|اقای|خانم|آقا)\s+/;
const TITLE_PREFIX = /^(آقای|اقای|خانم|آقا|سرکار\s+خانم|سرکار)\s+/;
const HONORIFIC_MIDDLE = /\s+خ\s+/g;
const normalizePersonName = (value) => {
@@ -28,14 +28,20 @@ const namesMatch = (left, right) => {
const normalizePhone = (raw) => {
if (raw == null || raw === '') return '';
let digits = toEnglishDigits(raw).replace(/\D/g, '');
if (digits.startsWith('98') && digits.length === 12) digits = `0${digits.slice(2)}`;
if (digits.startsWith('98') && digits.length >= 12) digits = `0${digits.slice(2)}`;
if (digits.length === 10 && digits.startsWith('9')) digits = `0${digits}`;
if (digits.length === 11 && digits.startsWith('9')) digits = `0${digits.slice(0, 10)}`;
if (digits.length > 11 && digits.startsWith('09')) digits = digits.slice(0, 11);
return digits;
};
const normalizeNationalId = (raw) => {
if (raw == null || raw === '') return '';
return toEnglishDigits(raw).replace(/\D/g, '');
let digits = toEnglishDigits(raw).replace(/\D/g, '');
if (digits && digits.length < 10 && /^\d+$/.test(digits)) {
digits = digits.padStart(10, '0');
}
return digits;
};
const mapAttendanceStatus = (raw) => {
@@ -13,8 +13,12 @@ const {
describe('importHelpers', () => {
it('normalizes honorifics so spreadsheet names match database users', () => {
assert.equal(normalizePersonName('مریم خ پناهیان'), 'مریم پناهیان');
assert.equal(normalizePersonName('سرکار فاطمه'), 'فاطمه');
assert.equal(normalizePersonName('سرکار خانم جاذبی'), 'جاذبی');
assert.ok(namesMatch('آقای رضایی', 'آقای رضایی'));
assert.equal(normalizePhone(9168942141), '09168942141');
assert.equal(normalizePhone('93568933888'), '09356893388');
assert.equal(normalizePhone('09161117529'), '09161117529');
});
it('maps spreadsheet attendance marks', () => {
+12
View File
@@ -32,6 +32,18 @@ const professorSchema = new mongoose.Schema({
trim: true,
lowercase: true
},
cardNumber: {
type: String,
trim: true
},
shabaNumber: {
type: String,
trim: true
},
bio: {
type: String,
trim: true
},
expertise: [{
type: String,
trim: true
+15 -1
View File
@@ -13,7 +13,10 @@ const createProfessor = async (data) => {
surname: String(data.surname || '').trim(),
nationalIdCode: String(data.nationalIdCode || data.nationalId || '').trim(),
phoneNumber: String(data.phoneNumber || data.phone || '').trim(),
email: data.email ? String(data.email).trim().toLowerCase() : undefined
email: data.email ? String(data.email).trim().toLowerCase() : undefined,
cardNumber: data.cardNumber ? String(data.cardNumber).trim() : undefined,
shabaNumber: data.shabaNumber || data.iban ? String(data.shabaNumber || data.iban).trim() : undefined,
bio: data.bio ? String(data.bio).trim() : undefined
};
const existing = await Professor.findOne({
@@ -73,6 +76,17 @@ const updateProfessor = async (id, updateData) => {
if (payload.email !== undefined) {
payload.email = payload.email ? String(payload.email).trim().toLowerCase() : undefined;
}
if (payload.cardNumber !== undefined) {
payload.cardNumber = payload.cardNumber ? String(payload.cardNumber).trim() : undefined;
}
if (payload.shabaNumber !== undefined || payload.iban !== undefined) {
const val = payload.shabaNumber || payload.iban;
payload.shabaNumber = val ? String(val).trim() : undefined;
delete payload.iban;
}
if (payload.bio !== undefined) {
payload.bio = payload.bio ? String(payload.bio).trim() : undefined;
}
Object.assign(professor, payload);
await professor.save();
+8
View File
@@ -79,6 +79,14 @@ const userSchema = new mongoose.Schema({
type: String,
trim: true
},
cardNumber: {
type: String,
trim: true
},
shabaNumber: {
type: String,
trim: true
},
username: {
type: String,
required: true,
+9
View File
@@ -84,6 +84,8 @@ const pickProfileFields = (body = {}) => {
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)
};
@@ -257,6 +259,13 @@ const updateUser = async (id, body) => {
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) || [];
}