feat: add append-only course/user data import and expand user profile

Support uploading structured JSON to create courses, classes, and students without wiping existing data, and merge user name fields while adding gender and registration details from source sheets.
This commit is contained in:
2026-08-15 01:31:44 +03:30
parent 35704409ec
commit bf5e66fa95
19 changed files with 2314 additions and 569 deletions
+66 -39
View File
@@ -9,6 +9,7 @@ const { calculateMeta } = require('../../utils/pagination');
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
const { sendAccountCreatedSms } = require('../../utils/senders/smsMessages');
const logger = require('../../utils/logger');
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
const POPULATE_ROLE = { path: 'role', select: 'name permissions' };
const SAFE_FIELDS = '-passwordHash -refreshTokens';
@@ -37,23 +38,46 @@ const allocateUniqueUsername = async (preferred) => {
throw new AppError('INTERNAL_SERVER_ERROR', null, 'Could not generate a unique username');
};
const pickProfileFields = (body = {}) => {
const fullName = mergeFullName(body.name, body.surname);
const fields = {
name: fullName || undefined,
gender: normalizeGender(body.gender),
nationalIdCode: body.nationalIdCode || body.nationalId || undefined,
phoneNumber: body.phoneNumber || body.phone || undefined,
email: body.email,
address: body.address,
birthCertificateNumber: body.birthCertificateNumber,
postalCode: body.postalCode,
placeOfIssue: body.placeOfIssue,
fatherName: body.fatherName,
birthDate: body.birthDate || undefined,
education: body.education,
parentPhoneNumber: body.parentPhoneNumber,
preferredMessenger: normalizePreferredMessengers(body.preferredMessenger)
};
Object.keys(fields).forEach((key) => {
if (fields[key] === '' || fields[key] === null || fields[key] === undefined) {
delete fields[key];
}
});
return fields;
};
const signUp = async (body) => {
const { name, surname, nationalId, nationalIdCode, phoneNumber, phone, username, password, email, address, preferredMessenger } = body;
const profile = pickProfileFields(body);
const { username, password } = body;
const userRole = await Role.findOne({ name: 'User' });
if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
const passwordHash = await bcrypt.hash(password, 10);
const user = await User.create({
name,
surname,
nationalIdCode: nationalIdCode || nationalId,
phoneNumber: phoneNumber || phone,
...profile,
username,
passwordHash,
email,
address,
preferredMessenger: normalizePreferredMessengers(preferredMessenger),
role: userRole._id
});
@@ -75,8 +99,9 @@ const getAllUsers = async (query) => {
if (query.search) {
filter.$or = [
{ name: new RegExp(query.search, 'i') },
{ surname: new RegExp(query.search, 'i') },
{ username: new RegExp(query.search, 'i') }
{ username: new RegExp(query.search, 'i') },
{ nationalIdCode: new RegExp(query.search, 'i') },
{ phoneNumber: new RegExp(query.search, 'i') }
];
}
if (query.isActive !== undefined) filter.isActive = query.isActive === 'true';
@@ -92,20 +117,12 @@ const getAllUsers = async (query) => {
const searchUsers = async (query) => getAllUsers(query);
const createUserAdmin = async (body) => {
const profile = pickProfileFields(body);
const {
name,
surname,
nationalIdCode,
nationalId,
phoneNumber,
phone,
username: requestedUsername,
password: requestedPassword,
email,
roleId,
role,
address,
preferredMessenger,
role
} = body;
let roleObj = null;
@@ -120,25 +137,18 @@ const createUserAdmin = async (body) => {
const plainPassword = (requestedPassword && String(requestedPassword).trim()) || generateSimplePassword();
const username = await allocateUniqueUsername(requestedUsername);
const passwordHash = await bcrypt.hash(plainPassword, 10);
const resolvedPhone = phoneNumber || phone;
const user = await User.create({
name,
surname,
nationalIdCode: nationalIdCode || nationalId,
phoneNumber: resolvedPhone,
...profile,
username,
passwordHash,
email,
address,
preferredMessenger: normalizePreferredMessengers(preferredMessenger),
role: roleObj._id,
role: roleObj._id
});
try {
await sendAccountCreatedSms(resolvedPhone, username, plainPassword);
await sendAccountCreatedSms(profile.phoneNumber, username, plainPassword);
} catch (err) {
logger.error(`[createUserAdmin] Account SMS failed for ${resolvedPhone}: ${err.message}`);
logger.error(`[createUserAdmin] Account SMS failed for ${profile.phoneNumber}: ${err.message}`);
}
const created = await User.findById(user._id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean();
@@ -146,8 +156,8 @@ const createUserAdmin = async (body) => {
...created,
generatedCredentials: {
username,
password: plainPassword,
},
password: plainPassword
}
};
};
@@ -164,6 +174,9 @@ const updateUser = async (id, body) => {
passwordHash,
refreshTokens,
preferredMessenger,
surname,
name,
gender,
_id,
id: bodyId,
createdAt,
@@ -173,8 +186,12 @@ const updateUser = async (id, body) => {
} = body;
const update = { ...rest };
const mergedName = mergeFullName(name, surname);
if (mergedName) update.name = mergedName;
const normalizedGender = normalizeGender(gender);
if (normalizedGender) update.gender = normalizedGender;
// Map frontend field aliases to schema fields
if (nationalIdCode || nationalId) {
update.nationalIdCode = nationalIdCode || nationalId;
}
@@ -188,12 +205,13 @@ const updateUser = async (id, body) => {
update.preferredMessenger = normalizePreferredMessengers(preferredMessenger) || [];
}
// Never overwrite username with an empty value on update
if (typeof username === 'string' && username.trim()) {
update.username = username.trim();
}
// Strip empty strings so required validators are not tripped
// Legacy field — drop if clients still send it
delete update.surname;
Object.keys(update).forEach((key) => {
if (key === 'preferredMessenger') return;
if (update[key] === '' || update[key] === null || update[key] === undefined) {
@@ -218,7 +236,7 @@ const deleteUser = async (id) => {
if (!user) throw new AppError('USER_NOT_FOUND');
};
const enrollUserInCourse = async (userId, courseId, actorId) => {
const enrollUserInCourse = async (userId, courseId) => {
const user = await User.findById(userId);
if (!user) throw new AppError('USER_NOT_FOUND');
@@ -229,4 +247,13 @@ const enrollUserInCourse = async (userId, courseId, actorId) => {
return User.findById(userId).select(SAFE_FIELDS).populate('courses').lean();
};
module.exports = { signUp, getUserById, getAllUsers, searchUsers, createUserAdmin, updateUser, deleteUser, enrollUserInCourse };
module.exports = {
signUp,
getUserById,
getAllUsers,
searchUsers,
createUserAdmin,
updateUser,
deleteUser,
enrollUserInCourse
};