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:
@@ -0,0 +1,28 @@
|
||||
// /components/dataImport/dataImportController.js
|
||||
'use strict';
|
||||
|
||||
const catchAsync = require('../../utils/catchAsync');
|
||||
const dataImportService = require('./dataImportService');
|
||||
const AppError = require('../../utils/AppError');
|
||||
const { successResponse } = require('../../utils/apiResponse');
|
||||
|
||||
exports.importJson = catchAsync(async (req, res) => {
|
||||
let payload = req.body;
|
||||
|
||||
if (req.file?.buffer) {
|
||||
try {
|
||||
payload = JSON.parse(req.file.buffer.toString('utf8'));
|
||||
} catch {
|
||||
throw new AppError('VALIDATION_FAILED', null, 'Uploaded file is not valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
// Allow { data: { courses: [...] } } wrappers
|
||||
if (payload?.data?.courses) payload = payload.data;
|
||||
if (!payload?.courses && payload?.version && payload?.courses == null) {
|
||||
throw new AppError('VALIDATION_FAILED', null, 'Invalid import payload');
|
||||
}
|
||||
|
||||
const result = await dataImportService.importData(payload);
|
||||
return successResponse(res, 200, 'Data imported successfully', result);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
// /components/dataImport/dataImportRoutes.js
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const dataImportController = require('./dataImportController');
|
||||
const authMiddleware = require('../../middlewares/authMiddleware');
|
||||
const requireSuperAdmin = require('../../middlewares/requireSuperAdmin');
|
||||
|
||||
const router = express.Router();
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 20 * 1024 * 1024 }
|
||||
});
|
||||
|
||||
router.use(authMiddleware);
|
||||
router.use(requireSuperAdmin);
|
||||
|
||||
router.post('/', upload.single('file'), dataImportController.importJson);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,258 @@
|
||||
// /components/dataImport/dataImportService.js
|
||||
'use strict';
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
const Course = require('../courses/courseModel');
|
||||
const Class = require('../classes/classModel');
|
||||
const User = require('../users/userModel');
|
||||
const Role = require('../roles/roleModel');
|
||||
const AppError = require('../../utils/AppError');
|
||||
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
|
||||
const { mergeFullName, normalizeGender } = require('../../utils/userProfile');
|
||||
|
||||
const toEnglishDigits = (value) =>
|
||||
String(value ?? '')
|
||||
.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d))
|
||||
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d));
|
||||
|
||||
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.length === 10 && digits.startsWith('9')) digits = `0${digits}`;
|
||||
return digits;
|
||||
};
|
||||
|
||||
const normalizeNationalId = (raw) => {
|
||||
if (raw == null || raw === '') return '';
|
||||
return toEnglishDigits(raw).replace(/\D/g, '');
|
||||
};
|
||||
|
||||
const allocateUniqueUsername = async () => {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const username = generateUsername();
|
||||
if (!(await User.exists({ username }))) return username;
|
||||
}
|
||||
throw new AppError('INTERNAL_SERVER_ERROR', null, 'Could not generate a unique username');
|
||||
};
|
||||
|
||||
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 })) {
|
||||
i += 1;
|
||||
candidate = `${base.slice(0, 7)}${String(i).padStart(3, '0')}`.slice(0, 10);
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const findExistingUser = async ({ nationalIdCode, phoneNumber }) => {
|
||||
if (nationalIdCode) {
|
||||
const byId = await User.findOne({ nationalIdCode });
|
||||
if (byId) return byId;
|
||||
}
|
||||
if (phoneNumber) {
|
||||
const byPhone = await User.findOne({ phoneNumber });
|
||||
if (byPhone) return byPhone;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const applyStudentProfile = (user, student) => {
|
||||
const name = mergeFullName(student.name, student.surname);
|
||||
if (name) user.name = name;
|
||||
|
||||
const gender = normalizeGender(student.gender);
|
||||
if (gender) user.gender = gender;
|
||||
|
||||
const optionalFields = [
|
||||
'address',
|
||||
'birthCertificateNumber',
|
||||
'postalCode',
|
||||
'placeOfIssue',
|
||||
'fatherName',
|
||||
'education',
|
||||
'parentPhoneNumber'
|
||||
];
|
||||
for (const field of optionalFields) {
|
||||
if (student[field] && !user[field]) {
|
||||
user[field] = String(student[field]).trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (student.birthDate && !user.birthDate) {
|
||||
const date = new Date(student.birthDate);
|
||||
if (!Number.isNaN(date.getTime())) user.birthDate = date;
|
||||
}
|
||||
};
|
||||
|
||||
const upsertStudent = async (student, userRole, stats, warnings) => {
|
||||
const phoneNumber = normalizePhone(student.phoneNumber || student.phone || student.parentPhoneNumber);
|
||||
let nationalIdCode = normalizeNationalId(student.nationalIdCode || student.nationalId);
|
||||
|
||||
if (!phoneNumber && !nationalIdCode) {
|
||||
warnings.push({
|
||||
reason: 'missing_identity',
|
||||
student: { name: student.name }
|
||||
});
|
||||
stats.studentsSkipped += 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
let user = await findExistingUser({ nationalIdCode, phoneNumber });
|
||||
if (user) {
|
||||
applyStudentProfile(user, student);
|
||||
if (phoneNumber && !user.phoneNumber) user.phoneNumber = phoneNumber;
|
||||
await user.save();
|
||||
stats.studentsUpdated += 1;
|
||||
return user;
|
||||
}
|
||||
|
||||
if (!phoneNumber) {
|
||||
warnings.push({
|
||||
reason: 'missing_phone',
|
||||
student: { name: student.name, nationalIdCode }
|
||||
});
|
||||
stats.studentsSkipped += 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!nationalIdCode) {
|
||||
nationalIdCode = await allocatePlaceholderNationalId(phoneNumber);
|
||||
warnings.push({
|
||||
reason: 'placeholder_national_id',
|
||||
student: { name: student.name, phoneNumber, nationalIdCode }
|
||||
});
|
||||
}
|
||||
|
||||
// Collision: national id exists with different phone, or reverse
|
||||
const conflictById = await User.findOne({ nationalIdCode });
|
||||
if (conflictById) {
|
||||
applyStudentProfile(conflictById, student);
|
||||
await conflictById.save();
|
||||
stats.studentsUpdated += 1;
|
||||
return conflictById;
|
||||
}
|
||||
|
||||
const username = await allocateUniqueUsername();
|
||||
const plainPassword = generateSimplePassword();
|
||||
const passwordHash = await bcrypt.hash(plainPassword, 10);
|
||||
|
||||
user = await User.create({
|
||||
name: mergeFullName(student.name, student.surname) || `کاربر ${phoneNumber}`,
|
||||
gender: normalizeGender(student.gender),
|
||||
nationalIdCode,
|
||||
phoneNumber,
|
||||
address: student.address,
|
||||
birthCertificateNumber: student.birthCertificateNumber,
|
||||
postalCode: student.postalCode,
|
||||
placeOfIssue: student.placeOfIssue,
|
||||
fatherName: student.fatherName,
|
||||
birthDate: student.birthDate ? new Date(student.birthDate) : undefined,
|
||||
education: student.education,
|
||||
parentPhoneNumber: student.parentPhoneNumber
|
||||
? normalizePhone(student.parentPhoneNumber)
|
||||
: undefined,
|
||||
username,
|
||||
passwordHash,
|
||||
role: userRole._id,
|
||||
preferredMessenger: ['SMS']
|
||||
});
|
||||
|
||||
stats.studentsCreated += 1;
|
||||
return user;
|
||||
};
|
||||
|
||||
const importData = async (payload) => {
|
||||
if (!payload || !Array.isArray(payload.courses)) {
|
||||
throw new AppError('VALIDATION_FAILED', null, 'Invalid import payload: courses array required');
|
||||
}
|
||||
|
||||
const userRole = await Role.findOne({ name: 'User' });
|
||||
if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
|
||||
|
||||
const stats = {
|
||||
coursesCreated: 0,
|
||||
coursesReused: 0,
|
||||
classesCreated: 0,
|
||||
classesReused: 0,
|
||||
studentsCreated: 0,
|
||||
studentsUpdated: 0,
|
||||
studentsSkipped: 0,
|
||||
enrollmentsAdded: 0
|
||||
};
|
||||
const warnings = [];
|
||||
|
||||
for (const courseInput of payload.courses) {
|
||||
const title = String(courseInput.title || '').trim();
|
||||
if (!title) continue;
|
||||
|
||||
const type = courseInput.type === 'Private' ? 'Private' : 'General';
|
||||
let course = await Course.findOne({ title });
|
||||
if (!course) {
|
||||
course = await Course.create({
|
||||
title,
|
||||
type,
|
||||
price: Number(courseInput.price) || 0,
|
||||
showOnFrontend: false,
|
||||
description: courseInput.description || ''
|
||||
});
|
||||
stats.coursesCreated += 1;
|
||||
} else {
|
||||
stats.coursesReused += 1;
|
||||
if (type === 'Private' && course.type !== 'Private') {
|
||||
course.type = 'Private';
|
||||
await course.save();
|
||||
}
|
||||
}
|
||||
|
||||
const classes = Array.isArray(courseInput.classes) ? courseInput.classes : [];
|
||||
for (const classInput of classes) {
|
||||
const className = String(classInput.name || '').trim();
|
||||
if (!className) continue;
|
||||
|
||||
let cls = await Class.findOne({ name: className, course: course._id });
|
||||
if (!cls) {
|
||||
cls = await Class.create({
|
||||
name: className,
|
||||
course: course._id,
|
||||
startDate: classInput.startDate ? new Date(classInput.startDate) : undefined,
|
||||
tuitionFee: Number(classInput.tuitionFee) || Number(courseInput.price) || 0,
|
||||
isActive: true
|
||||
});
|
||||
stats.classesCreated += 1;
|
||||
} else {
|
||||
stats.classesReused += 1;
|
||||
if (!cls.startDate && classInput.startDate) {
|
||||
cls.startDate = new Date(classInput.startDate);
|
||||
await cls.save();
|
||||
}
|
||||
}
|
||||
|
||||
const students = Array.isArray(classInput.students) ? classInput.students : [];
|
||||
for (const student of students) {
|
||||
const user = await upsertStudent(student, userRole, stats, warnings);
|
||||
if (!user) continue;
|
||||
|
||||
if (!user.courses.map(String).includes(String(course._id))) {
|
||||
user.courses.push(course._id);
|
||||
await user.save();
|
||||
}
|
||||
|
||||
if (!cls.students.map(String).includes(String(user._id))) {
|
||||
cls.students.push(user._id);
|
||||
stats.enrollmentsAdded += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await cls.save();
|
||||
}
|
||||
}
|
||||
|
||||
return { stats, warnings };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
importData
|
||||
};
|
||||
Reference in New Issue
Block a user