feat: add professor import support, user account creation, and IBAN/card fields
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) || [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user