diff --git a/components/classes/classService.js b/components/classes/classService.js index 4052266..42e4b13 100644 --- a/components/classes/classService.js +++ b/components/classes/classService.js @@ -9,7 +9,7 @@ const Payment = require('../payments/paymentModel'); const AppError = require('../../utils/AppError'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages'); -const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime } = require('../../utils/classSchedule'); +const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime, calculateClassEndDate } = require('../../utils/classSchedule'); const { resolveNotifyFlags } = require('../../utils/notifyResolver'); const { notifyAction } = require('../../utils/actionNotify'); const logger = require('../../utils/logger'); @@ -18,6 +18,17 @@ const applyScheduleFields = (payload, body) => { if (body.days !== undefined) payload.days = normalizeWeekdays(body.days); if (body.startTime !== undefined) payload.startTime = normalizeClockTime(body.startTime); if (body.endTime !== undefined) payload.endTime = normalizeClockTime(body.endTime); + + const startDate = payload.startDate || body.startDate; + const days = payload.days || body.days; + const sessions = payload.numberOfSessions || body.numberOfSessions; + if (startDate && days?.length && sessions) { + const computedEnd = calculateClassEndDate(startDate, days, sessions); + if (computedEnd && (!payload.endDate || !body.endDate)) { + payload.endDate = computedEnd; + } + } + return payload; }; diff --git a/components/dataImport/dataImportService.js b/components/dataImport/dataImportService.js index ae0180b..f1d1c8d 100644 --- a/components/dataImport/dataImportService.js +++ b/components/dataImport/dataImportService.js @@ -63,10 +63,6 @@ const findExistingUser = async ({ nationalIdCode, phoneNumber, name }) => { const byId = await User.findOne({ nationalIdCode }); if (byId) return byId; } - if (phoneNumber) { - const byPhone = await User.findOne({ phoneNumber }); - if (byPhone) return byPhone; - } const target = normalizePersonName(name); if (target) { const firstToken = target.split(' ')[0]; @@ -76,6 +72,14 @@ const findExistingUser = async ({ nationalIdCode, phoneNumber, name }) => { const match = candidates.find((user) => namesMatch(user.name, target)); if (match) return match; } + if (phoneNumber) { + const byPhone = await User.findOne({ phoneNumber }); + if (byPhone) { + if (!nationalIdCode || !byPhone.nationalIdCode || byPhone.nationalIdCode === nationalIdCode) { + return byPhone; + } + } + } return null; }; @@ -108,7 +112,7 @@ const applyStudentProfile = (user, student) => { }; const upsertStudent = async (student, userRole, stats, warnings) => { - const phoneNumber = normalizePhone(student.phoneNumber || student.phone || student.parentPhoneNumber); + let phoneNumber = normalizePhone(student.phoneNumber || student.phone || student.parentPhoneNumber); let nationalIdCode = normalizeNationalId(student.nationalIdCode || student.nationalId); if (!phoneNumber && !nationalIdCode) { @@ -129,23 +133,22 @@ const upsertStudent = async (student, userRole, stats, warnings) => { 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); + nationalIdCode = await allocatePlaceholderNationalId(phoneNumber || student.name); warnings.push({ reason: 'placeholder_national_id', student: { name: student.name, phoneNumber, nationalIdCode } }); } + if (!phoneNumber) { + phoneNumber = await allocatePlaceholderPhone(nationalIdCode || student.name); + warnings.push({ + reason: 'placeholder_phone', + student: { name: student.name, nationalIdCode, placeholderPhone: phoneNumber } + }); + } + const conflictById = await User.findOne({ nationalIdCode }); if (conflictById) { applyStudentProfile(conflictById, student); @@ -154,6 +157,17 @@ const upsertStudent = async (student, userRole, stats, warnings) => { return conflictById; } + const conflictByPhone = await User.findOne({ phoneNumber }); + if (conflictByPhone) { + const originalPhone = phoneNumber; + phoneNumber = await allocatePlaceholderPhone(nationalIdCode || student.name); + if (!student.parentPhoneNumber) student.parentPhoneNumber = originalPhone; + warnings.push({ + reason: 'phone_conflict_placeholder_allocated', + student: { name: student.name, originalPhone, placeholderPhone: phoneNumber } + }); + } + const username = await allocateUniqueUsername(); const plainPassword = generateSimplePassword(); const passwordHash = await bcrypt.hash(plainPassword, 10); @@ -194,13 +208,56 @@ const enrollUserInClass = async (user, course, cls, stats) => { } }; +const WEEKDAY_STR_TO_NUM = { + sunday: 0, + monday: 1, + tuesday: 2, + wednesday: 3, + thursday: 4, + friday: 5, + saturday: 6, + 'یکشنبه': 0, + 'یک شنبه': 0, + 'دوشنبه': 1, + 'دو شنبه': 1, + 'سه شنبه': 2, + 'سه‌شنبه': 2, + 'سهشنبه': 2, + 'چهارشنبه': 3, + 'چهار شنبه': 3, + 'پنجشنبه': 4, + 'پنج شنبه': 4, + 'جمعه': 5, + 'شنبه': 6 +}; + +const normalizeWeekdaysInput = (days) => { + if (!Array.isArray(days)) return []; + const res = []; + for (const d of days) { + if (typeof d === 'number' && d >= 0 && d <= 6) { + if (!res.includes(d)) res.push(d); + } else if (typeof d === 'string') { + const lower = d.trim().toLowerCase(); + if (WEEKDAY_STR_TO_NUM[lower] != null) { + const num = WEEKDAY_STR_TO_NUM[lower]; + if (!res.includes(num)) res.push(num); + } else if (!Number.isNaN(Number(d))) { + const num = Number(d); + if (num >= 0 && num <= 6 && !res.includes(num)) res.push(num); + } + } + } + return res; +}; + const applyClassSchedule = (cls, classInput) => { if (classInput.startDate && !cls.startDate) { const startDate = parseImportDate(classInput.startDate); if (startDate) cls.startDate = startDate; } if (Array.isArray(classInput.days) && classInput.days.length) { - cls.days = classInput.days; + cls.days = normalizeWeekdaysInput(classInput.days); } if (classInput.startTime) cls.startTime = String(classInput.startTime).trim(); if (classInput.endTime) cls.endTime = String(classInput.endTime).trim(); @@ -581,7 +638,7 @@ const importData = async (rawPayload) => { course: course._id, startDate: parseImportDate(classInput.startDate) || undefined, tuitionFee: Number(classInput.tuitionFee) || Number(courseInput.price) || 0, - days: Array.isArray(classInput.days) ? classInput.days : [], + days: normalizeWeekdaysInput(classInput.days), startTime: classInput.startTime || '', endTime: classInput.endTime || '', isActive: true diff --git a/components/professors/professorService.js b/components/professors/professorService.js index 01e0770..0e74c81 100644 --- a/components/professors/professorService.js +++ b/components/professors/professorService.js @@ -5,14 +5,22 @@ const AppError = require('../../utils/AppError'); const eventEmitter = require('../../events/eventEmitter'); const EVENT_NAMES = require('../../constants/eventNames'); const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination'); +const { allocatePlaceholderNationalId } = require('../../utils/nationalId'); const createProfessor = async (data) => { + let nationalIdCode = String(data.nationalIdCode || data.nationalId || '').trim(); + const phoneNumber = String(data.phoneNumber || data.phone || '').trim(); + + if (!nationalIdCode) { + nationalIdCode = await allocatePlaceholderNationalId(phoneNumber); + } + const payload = { ...data, name: String(data.name || '').trim(), surname: String(data.surname || '').trim(), - nationalIdCode: String(data.nationalIdCode || data.nationalId || '').trim(), - phoneNumber: String(data.phoneNumber || data.phone || '').trim(), + nationalIdCode, + phoneNumber, 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, diff --git a/components/users/userService.js b/components/users/userService.js index 20b4830..afdcb64 100644 --- a/components/users/userService.js +++ b/components/users/userService.js @@ -17,6 +17,7 @@ const { assertCanResetPasswordAndSms, isCredentialsSmsDelivered } = require('./passwordReset'); +const { allocatePlaceholderNationalId } = require('../../utils/nationalId'); const normalizeAdminNotes = (value) => { if (value == null) return undefined; @@ -106,6 +107,10 @@ const signUp = async (body) => { const userRole = await Role.findOne({ name: 'User' }); if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND'); + if (!profile.nationalIdCode) { + profile.nationalIdCode = await allocatePlaceholderNationalId(profile.phoneNumber); + } + const passwordHash = await bcrypt.hash(password, 10); const user = await User.create({ ...profile, @@ -171,6 +176,10 @@ const createUserAdmin = async (body) => { } if (!roleObj) throw new AppError('DEFAULT_ROLE_NOT_FOUND'); + if (!profile.nationalIdCode) { + profile.nationalIdCode = await allocatePlaceholderNationalId(profile.phoneNumber); + } + const plainPassword = (requestedPassword && String(requestedPassword).trim()) || generateSimplePassword(); const username = await allocateUniqueUsername(requestedUsername); const passwordHash = await bcrypt.hash(plainPassword, 10); diff --git a/utils/classSchedule.js b/utils/classSchedule.js index 07f1720..e8c9873 100644 --- a/utils/classSchedule.js +++ b/utils/classSchedule.js @@ -109,6 +109,31 @@ const buildClassScheduleContext = (classDoc, sessions = [], currentSession = nul }; }; +const calculateClassEndDate = (startDate, days = [], numberOfSessions = 0) => { + const date = toDate(startDate); + const countNeeded = parseInt(numberOfSessions, 10); + if (!date || isNaN(countNeeded) || countNeeded < 1) return null; + const validDays = normalizeWeekdays(days); + if (!validDays.length) return null; + + let current = new Date(date); + let count = 0; + let lastMatchingDate = null; + let safetyLimit = 365 * 5; + + while (count < countNeeded && safetyLimit > 0) { + safetyLimit--; + if (validDays.includes(current.getDay())) { + count++; + lastMatchingDate = new Date(current); + } + if (count >= countNeeded) break; + current.setDate(current.getDate() + 1); + } + + return lastMatchingDate; +}; + module.exports = { IRAN_WEEK_ORDER, PERSIAN_WEEKDAYS, @@ -118,5 +143,6 @@ module.exports = { formatClassStartDate, normalizeWeekdays, normalizeClockTime, - buildClassScheduleContext + buildClassScheduleContext, + calculateClassEndDate }; diff --git a/utils/nationalId.js b/utils/nationalId.js new file mode 100644 index 0000000..86f3607 --- /dev/null +++ b/utils/nationalId.js @@ -0,0 +1,25 @@ +'use strict'; + +const User = require('../components/users/userModel'); +const Professor = require('../components/professors/professorModel'); + +/** + * Generates a unique 10-character placeholder national ID (starting with TMP). + * @param {string} [seed] - Optional seed like phone number or timestamp + * @returns {Promise} + */ +const allocatePlaceholderNationalId = async (seed) => { + const cleanDigits = String(seed || '').replace(/\D/g, ''); + const baseSuffix = (cleanDigits.slice(-7) || Date.now().toString().slice(-7)).padStart(7, '0').slice(0, 7); + let candidate = `TMP${baseSuffix}`.slice(0, 10); + let i = 0; + while ((await User.exists({ nationalIdCode: candidate })) || (await Professor.exists({ nationalIdCode: candidate }))) { + i += 1; + candidate = `TMP${String(i).padStart(7, '0')}`.slice(0, 10); + } + return candidate; +}; + +module.exports = { + allocatePlaceholderNationalId +};