From 0a022c0917849184e647adc05cc9333227867397 Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Tue, 25 Aug 2026 03:48:19 +0330 Subject: [PATCH] feat(professors,sms): merge professor with users, add existing user link, professor portal endpoint, and SMS bypass list --- components/dataImport/dataImportService.js | 64 +- components/professors/professorController.js | 7 + components/professors/professorModel.js | 94 ++- components/professors/professorRoutes.js | 7 + components/professors/professorService.js | 657 ++++++++++++++---- .../professors/professorUserMerge.test.js | 60 ++ components/settings/messagingFlags.js | 58 +- components/settings/settingModel.js | 27 + components/settings/settingService.js | 46 +- components/settings/smsBypass.test.js | 37 + package.json | 2 +- utils/actionNotify.js | 4 +- utils/senders/sms.base.js | 3 +- utils/senders/smsMessages.js | 4 +- 14 files changed, 865 insertions(+), 205 deletions(-) create mode 100644 components/professors/professorUserMerge.test.js create mode 100644 components/settings/smsBypass.test.js diff --git a/components/dataImport/dataImportService.js b/components/dataImport/dataImportService.js index f1d1c8d..39fb561 100644 --- a/components/dataImport/dataImportService.js +++ b/components/dataImport/dataImportService.js @@ -484,44 +484,6 @@ const upsertProfessorAndUser = async (profInput, professorRole, userRole, stats, }); } - 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) { @@ -529,7 +491,7 @@ const upsertProfessorAndUser = async (profInput, professorRole, userRole, stats, 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)) { + if (targetRole && String(user.role) !== String(targetRole._id)) { user.role = targetRole._id; } await user.save(); @@ -555,6 +517,30 @@ const upsertProfessorAndUser = async (profInput, professorRole, userRole, stats, stats.studentsCreated += 1; } + let professorDoc = await Professor.findOne({ user: user._id }); + if (!professorDoc) { + professorDoc = await Professor.findOne({ + $or: [ + { nationalIdCode }, + { phoneNumber } + ] + }); + } + + if (professorDoc) { + professorDoc.user = user._id; + if (profInput.bio && !professorDoc.bio) professorDoc.bio = profInput.bio; + await professorDoc.save(); + stats.professorsUpdated += 1; + } else { + professorDoc = await Professor.create({ + user: user._id, + bio: profInput.bio || undefined, + isActive: true + }); + stats.professorsCreated += 1; + } + return { professor: professorDoc, user }; }; diff --git a/components/professors/professorController.js b/components/professors/professorController.js index 7d1dd19..c19c2ff 100644 --- a/components/professors/professorController.js +++ b/components/professors/professorController.js @@ -1,4 +1,5 @@ // /components/professors/professorController.js +'use strict'; const catchAsync = require('../../utils/catchAsync'); const professorService = require('./professorService'); @@ -39,3 +40,9 @@ exports.search = catchAsync(async (req, res, next) => { const { data, meta } = await professorService.searchProfessors(req.query); return listResponse(res, 200, data, meta); }); + +exports.getPortal = catchAsync(async (req, res, next) => { + const userId = req.user?._id; + const data = await professorService.getProfessorPortalData(userId); + return successResponse(res, 200, 'Professor teaching portal data retrieved successfully', data); +}); diff --git a/components/professors/professorModel.js b/components/professors/professorModel.js index 22ed16f..5e3f37b 100644 --- a/components/professors/professorModel.js +++ b/components/professors/professorModel.js @@ -1,45 +1,16 @@ // /components/professors/professorModel.js +'use strict'; const mongoose = require('mongoose'); const professorSchema = new mongoose.Schema({ - nationalIdCode: { - type: String, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', required: true, unique: true, - trim: true, index: true }, - name: { - type: String, - required: true, - trim: true - }, - surname: { - type: String, - required: true, - trim: true - }, - phoneNumber: { - type: String, - required: true, - unique: true, - trim: true, - index: true - }, - email: { - type: String, - trim: true, - lowercase: true - }, - cardNumber: { - type: String, - trim: true - }, - shabaNumber: { - type: String, - trim: true - }, bio: { type: String, trim: true @@ -57,7 +28,62 @@ const professorSchema = new mongoose.Schema({ default: true } }, { - timestamps: true + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +// Virtual properties delegating personal details to the linked User model +professorSchema.virtual('name').get(function () { + if (this.user && typeof this.user === 'object' && this.user.name) { + return this.user.name; + } + return undefined; +}); + +professorSchema.virtual('surname').get(function () { + if (this.user && typeof this.user === 'object' && this.user.name) { + const parts = String(this.user.name).trim().split(/\s+/); + if (parts.length > 1) { + return parts.slice(1).join(' '); + } + } + return ''; +}); + +professorSchema.virtual('nationalIdCode').get(function () { + if (this.user && typeof this.user === 'object') { + return this.user.nationalIdCode; + } + return undefined; +}); + +professorSchema.virtual('phoneNumber').get(function () { + if (this.user && typeof this.user === 'object') { + return this.user.phoneNumber; + } + return undefined; +}); + +professorSchema.virtual('email').get(function () { + if (this.user && typeof this.user === 'object') { + return this.user.email; + } + return undefined; +}); + +professorSchema.virtual('cardNumber').get(function () { + if (this.user && typeof this.user === 'object') { + return this.user.cardNumber; + } + return undefined; +}); + +professorSchema.virtual('shabaNumber').get(function () { + if (this.user && typeof this.user === 'object') { + return this.user.shabaNumber; + } + return undefined; }); module.exports = mongoose.model('Professor', professorSchema); diff --git a/components/professors/professorRoutes.js b/components/professors/professorRoutes.js index e45d059..bb558dc 100644 --- a/components/professors/professorRoutes.js +++ b/components/professors/professorRoutes.js @@ -1,4 +1,5 @@ // /components/professors/professorRoutes.js +'use strict'; const express = require('express'); const professorController = require('./professorController'); @@ -11,6 +12,12 @@ const router = express.Router(); router.use(authMiddleware); +// Professor Self-Service Portal (accessible by authenticated professors) +router.get('/portal', professorController.getPortal); +router.get('/portal/me', professorController.getPortal); +router.get('/my-portal', professorController.getPortal); + +// Admin routes router.post('/admin/create', perm.requires(PERMISSIONS.PROFESSORS_CREATE), validateCreateProfessor, professorController.create); router.post('/admin/create-from-user', perm.requires(PERMISSIONS.PROFESSORS_CREATE), professorController.createFromUser); router.get('/admin/get-all', perm.requires(PERMISSIONS.PROFESSORS_READ), professorController.getAll); diff --git a/components/professors/professorService.js b/components/professors/professorService.js index 429468a..43de6de 100644 --- a/components/professors/professorService.js +++ b/components/professors/professorService.js @@ -1,53 +1,170 @@ // /components/professors/professorService.js +'use strict'; +const mongoose = require('mongoose'); const Professor = require('./professorModel'); const User = require('../users/userModel'); const Role = require('../roles/roleModel'); +const Class = require('../classes/classModel'); +const Session = require('../sessions/sessionModel'); +const Payment = require('../payments/paymentModel'); const AppError = require('../../utils/AppError'); const eventEmitter = require('../../events/eventEmitter'); const EVENT_NAMES = require('../../constants/eventNames'); -const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination'); +const { parsePaginationAndSort, calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { allocatePlaceholderNationalId } = require('../../utils/nationalId'); +const { generateUsername, generateSimplePassword } = require('../../utils/credentials'); +const bcrypt = require('bcryptjs'); +const { + resolveSessionDurationHours, + calculateProfessorPayout, + calculateHourlyShare, + calculatePercentageShare +} = require('../../utils/professorShare'); +const { formatJalali } = require('../../utils/jalaliDate'); + +const ensureProfessorRole = async () => { + let role = await Role.findOne({ name: 'Professor' }); + if (!role) { + role = await Role.create({ + name: 'Professor', + description: 'استاد و مدرس دوره‌ها', + permissions: ['professors:read', 'classes:read', 'sessions:read', 'attendances:read'], + isSystem: true + }); + } + return role; +}; + +const formatProfessorDoc = (doc) => { + if (!doc) return null; + const raw = doc.toObject ? doc.toObject({ virtuals: true }) : doc; + const user = raw.user && typeof raw.user === 'object' ? raw.user : null; + + const rawName = String(user?.name || raw.name || '').trim(); + const parts = rawName.split(/\s+/); + let firstName = rawName; + let surname = ''; + if (parts.length > 1) { + firstName = parts[0]; + surname = parts.slice(1).join(' '); + } + + return { + _id: raw._id, + id: raw._id, + user: user ? (user._id || user) : raw.user, + userId: user ? (user._id || user) : raw.user, + name: firstName, + surname: surname || raw.surname || '', + fullName: rawName, + nationalIdCode: user?.nationalIdCode || raw.nationalIdCode || '', + nationalId: user?.nationalIdCode || raw.nationalIdCode || '', + phoneNumber: user?.phoneNumber || raw.phoneNumber || '', + phone: user?.phoneNumber || raw.phoneNumber || '', + email: user?.email || raw.email || '', + cardNumber: user?.cardNumber || raw.cardNumber || '', + shabaNumber: user?.shabaNumber || raw.shabaNumber || '', + bio: raw.bio || '', + expertise: Array.isArray(raw.expertise) ? raw.expertise : [], + courses: raw.courses || [], + isActive: raw.isActive !== false && (user ? user.isActive !== false : true), + createdAt: raw.createdAt, + updatedAt: raw.updatedAt + }; +}; const createProfessor = async (data) => { - let nationalIdCode = String(data.nationalIdCode || data.nationalId || '').trim(); + const professorRole = await ensureProfessorRole(); + let user = null; + + const rawUserId = data.userId || data.user || data.id; + if (rawUserId && mongoose.Types.ObjectId.isValid(rawUserId)) { + user = await User.findById(rawUserId); + } + const phoneNumber = String(data.phoneNumber || data.phone || '').trim(); + let nationalIdCode = String(data.nationalIdCode || data.nationalId || '').trim(); - if (!nationalIdCode) { - nationalIdCode = await allocatePlaceholderNationalId(phoneNumber); + if (!user && phoneNumber) { + user = await User.findOne({ phoneNumber }); + } + if (!user && nationalIdCode) { + user = await User.findOne({ nationalIdCode }); } - const payload = { - ...data, - name: String(data.name || '').trim(), - surname: String(data.surname || '').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, - bio: data.bio ? String(data.bio).trim() : undefined - }; + if (!user) { + if (!nationalIdCode) { + nationalIdCode = await allocatePlaceholderNationalId(phoneNumber || data.name); + } + const fullName = String(data.name || '').trim() + (data.surname ? ` ${String(data.surname).trim()}` : ''); + const username = generateUsername(); + const plainPassword = generateSimplePassword(); + const passwordHash = await bcrypt.hash(plainPassword, 10); - const existing = await Professor.findOne({ - $or: [ - { nationalIdCode: payload.nationalIdCode }, - { phoneNumber: payload.phoneNumber }, - ...(payload.email ? [{ email: payload.email }] : []) - ] + user = await User.create({ + name: fullName.trim() || `استاد ${phoneNumber}`, + nationalIdCode, + phoneNumber: phoneNumber || `0999${Date.now().toString().slice(-7)}`, + 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, + role: professorRole._id, + username, + passwordHash, + isActive: data.isActive !== false + }); + } else { + // Promote user role to Professor + if (String(user.role) !== String(professorRole._id)) { + user.role = professorRole._id; + } + if (data.name) { + const fullName = String(data.name).trim() + (data.surname ? ` ${String(data.surname).trim()}` : ''); + user.name = fullName.trim(); + } + if (data.email) user.email = String(data.email).trim().toLowerCase(); + if (data.cardNumber) user.cardNumber = String(data.cardNumber).trim(); + if (data.shabaNumber || data.iban) user.shabaNumber = String(data.shabaNumber || data.iban).trim(); + await user.save(); + } + + let professor = await Professor.findOne({ user: user._id }); + let expertise = []; + if (Array.isArray(data.expertise)) { + expertise = data.expertise.map(String).map((s) => s.trim()).filter(Boolean); + } else if (typeof data.expertise === 'string' && data.expertise.trim()) { + expertise = data.expertise.split(',').map((s) => s.trim()).filter(Boolean); + } + + if (professor) { + professor.bio = data.bio != null ? String(data.bio).trim() : professor.bio; + if (expertise.length > 0) professor.expertise = expertise; + if (data.courses) professor.courses = data.courses; + professor.isActive = data.isActive !== false; + await professor.save(); + } else { + professor = await Professor.create({ + user: user._id, + bio: data.bio ? String(data.bio).trim() : undefined, + expertise, + courses: data.courses || [], + isActive: data.isActive !== false + }); + } + + eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, { + professorId: professor._id, + name: user.name }); - if (existing) { - throw new AppError('PROFESSOR_ALREADY_EXISTS'); - } - const professor = await Professor.create(payload); - eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, { professorId: professor._id, name: `${professor.name} ${professor.surname}` }); - return professor; + const populated = await Professor.findById(professor._id).populate('user').populate('courses', 'title type price'); + return formatProfessorDoc(populated); }; const createProfessorFromUser = async (userId, additionalData = {}) => { if (!userId) { - throw new AppError('VALIDATION_FAILED', null, 'User ID is required'); + throw new AppError('VALIDATION_FAILED', null, 'شناسه کاربر الزامی است'); } const user = await User.findById(userId); @@ -55,84 +172,41 @@ const createProfessorFromUser = async (userId, additionalData = {}) => { throw new AppError('USER_NOT_FOUND'); } - const professorRole = await Role.findOne({ name: 'Professor' }); - if (!professorRole) { - throw new AppError('DEFAULT_ROLE_NOT_FOUND', null, 'Professor role not found'); - } - - // Update user role to Professor if not already + const professorRole = await ensureProfessorRole(); if (String(user.role) !== String(professorRole._id)) { user.role = professorRole._id; - await user.save(); } - // Determine name and surname let firstName = String(additionalData.name || '').trim(); let lastName = String(additionalData.surname || '').trim(); - - if (!firstName && !lastName) { - const rawName = String(user.name || '').trim(); - const parts = rawName.split(/\s+/); - if (parts.length > 1) { - firstName = parts[0]; - lastName = parts.slice(1).join(' '); - } else { - firstName = rawName || 'استاد'; - lastName = rawName || 'استاد'; - } - } else if (!lastName && firstName) { - lastName = firstName; - } else if (!firstName && lastName) { - firstName = lastName; + if (firstName || lastName) { + user.name = `${firstName} ${lastName}`.trim(); } - let nationalIdCode = String(additionalData.nationalIdCode || additionalData.nationalId || user.nationalIdCode || '').trim(); - const phoneNumber = String(additionalData.phoneNumber || additionalData.phone || user.phoneNumber || '').trim(); - - if (!nationalIdCode) { - nationalIdCode = await allocatePlaceholderNationalId(phoneNumber); + if (additionalData.email) user.email = String(additionalData.email).trim().toLowerCase(); + if (additionalData.cardNumber) user.cardNumber = String(additionalData.cardNumber).trim(); + if (additionalData.shabaNumber || additionalData.iban) { + user.shabaNumber = String(additionalData.shabaNumber || additionalData.iban).trim(); } + await user.save(); - const email = additionalData.email ? String(additionalData.email).trim().toLowerCase() : (user.email ? String(user.email).trim().toLowerCase() : undefined); - const cardNumber = additionalData.cardNumber ? String(additionalData.cardNumber).trim() : (user.cardNumber ? String(user.cardNumber).trim() : undefined); - const shabaNumber = additionalData.shabaNumber || additionalData.iban ? String(additionalData.shabaNumber || additionalData.iban).trim() : (user.shabaNumber ? String(user.shabaNumber).trim() : undefined); - const bio = additionalData.bio ? String(additionalData.bio).trim() : undefined; - let expertise = []; if (Array.isArray(additionalData.expertise)) { - expertise = additionalData.expertise.map(String).map(s => s.trim()).filter(Boolean); + expertise = additionalData.expertise.map(String).map((s) => s.trim()).filter(Boolean); } else if (typeof additionalData.expertise === 'string' && additionalData.expertise.trim()) { - expertise = additionalData.expertise.split(',').map(s => s.trim()).filter(Boolean); + expertise = additionalData.expertise.split(',').map((s) => s.trim()).filter(Boolean); } - // Check if a Professor record already exists for this nationalIdCode or phoneNumber - let professor = await Professor.findOne({ - $or: [ - { nationalIdCode }, - { phoneNumber } - ] - }); - + let professor = await Professor.findOne({ user: user._id }); if (professor) { - professor.name = firstName; - professor.surname = lastName; - if (email) professor.email = email; - if (cardNumber) professor.cardNumber = cardNumber; - if (shabaNumber) professor.shabaNumber = shabaNumber; - if (bio) professor.bio = bio; + if (additionalData.bio !== undefined) professor.bio = String(additionalData.bio).trim(); if (expertise.length > 0) professor.expertise = expertise; professor.isActive = true; await professor.save(); } else { professor = await Professor.create({ - name: firstName, - surname: lastName, - nationalIdCode, - phoneNumber, - email, - cardNumber, - shabaNumber, - bio, + user: user._id, + bio: additionalData.bio ? String(additionalData.bio).trim() : undefined, expertise, isActive: true }); @@ -140,73 +214,157 @@ const createProfessorFromUser = async (userId, additionalData = {}) => { eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, { professorId: professor._id, - name: `${professor.name} ${professor.surname}` + name: user.name }); + const populated = await Professor.findById(professor._id).populate('user').populate('courses', 'title type price'); const updatedUser = await User.findById(user._id) .select('-passwordHash -refreshTokens') .populate({ path: 'role', select: 'name permissions' }) .lean(); - return { professor, user: updatedUser }; + return { + professor: formatProfessorDoc(populated), + user: updatedUser + }; }; const getProfessorById = async (id) => { - const professor = await Professor.findById(id).populate('courses', 'title type price'); + let professor = null; + if (mongoose.Types.ObjectId.isValid(id)) { + professor = await Professor.findById(id) + .populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber role isActive') + .populate('courses', 'title type price'); + if (!professor) { + professor = await Professor.findOne({ user: id }) + .populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber role isActive') + .populate('courses', 'title type price'); + } + } + if (!professor) { throw new AppError('PROFESSOR_NOT_FOUND'); } - return professor; + + return formatProfessorDoc(professor); }; -const getAllProfessors = async (queryParams) => { - const { page, limit, skip, sort } = parsePaginationAndSort(queryParams); - const filter = buildFilterQuery(queryParams, ['name', 'surname', 'nationalIdCode', 'phoneNumber', 'email', 'expertise']); +const getAllProfessors = async (queryParams = {}) => { + const page = parseInt(queryParams.page, 10) || 1; + const limit = Math.min(parseInt(queryParams.limit, 10) || 20, 200); + const skip = (page - 1) * limit; + + const filter = {}; + if (queryParams.isActive !== undefined) { + filter.isActive = queryParams.isActive === 'true' || queryParams.isActive === true; + } + + const searchTerm = getSearchTerm(queryParams); + if (searchTerm) { + const searchRegex = new RegExp(escapeRegex(searchTerm), 'i'); + const matchedUsers = await User.find({ + $or: [ + { name: searchRegex }, + { phoneNumber: searchRegex }, + { nationalIdCode: searchRegex }, + { email: searchRegex } + ] + }).select('_id').lean(); + + const userIds = matchedUsers.map((u) => u._id); + filter.$or = [ + { user: { $in: userIds } }, + { bio: searchRegex }, + { expertise: searchRegex } + ]; + } const [professors, totalCount] = await Promise.all([ - Professor.find(filter).populate('courses', 'title').sort(sort).skip(skip).limit(limit), + Professor.find(filter) + .populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber role isActive') + .populate('courses', 'title type price') + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit), Professor.countDocuments(filter) ]); + const activeCoursesCounts = await Class.aggregate([ + { $match: { isDeleted: { $ne: true }, isActive: { $ne: false }, professor: { $in: professors.map((p) => p._id) } } }, + { $group: { _id: '$professor', count: { $sum: 1 } } } + ]); + const activeCountMap = Object.fromEntries(activeCoursesCounts.map((c) => [String(c._id), c.count])); + + const formatted = professors.map((p) => { + const item = formatProfessorDoc(p); + return { + ...item, + activeCoursesCount: activeCountMap[String(p._id)] || 0 + }; + }); + const meta = calculateMeta(totalCount, page, limit); - return { data: professors, meta }; + return { data: formatted, meta }; }; -const updateProfessor = async (id, updateData) => { - const professor = await Professor.findById(id); +const updateProfessor = async (id, updateData = {}) => { + let professor = await Professor.findById(id).populate('user'); + if (!professor && mongoose.Types.ObjectId.isValid(id)) { + professor = await Professor.findOne({ user: id }).populate('user'); + } if (!professor) { throw new AppError('PROFESSOR_NOT_FOUND'); } - const payload = { ...updateData }; - if (payload.nationalIdCode !== undefined || payload.nationalId !== undefined) { - payload.nationalIdCode = String(payload.nationalIdCode || payload.nationalId || '').trim(); - delete payload.nationalId; + if (updateData.bio !== undefined) { + professor.bio = updateData.bio ? String(updateData.bio).trim() : undefined; } - if (payload.phoneNumber !== undefined || payload.phone !== undefined) { - payload.phoneNumber = String(payload.phoneNumber || payload.phone || '').trim(); - delete payload.phone; + if (updateData.expertise !== undefined) { + if (Array.isArray(updateData.expertise)) { + professor.expertise = updateData.expertise.map(String).map((s) => s.trim()).filter(Boolean); + } else if (typeof updateData.expertise === 'string') { + professor.expertise = updateData.expertise.split(',').map((s) => s.trim()).filter(Boolean); + } } - if (payload.name !== undefined) payload.name = String(payload.name).trim(); - if (payload.surname !== undefined) payload.surname = String(payload.surname).trim(); - if (payload.email !== undefined) { - payload.email = payload.email ? String(payload.email).trim().toLowerCase() : undefined; + if (updateData.courses !== undefined) { + professor.courses = updateData.courses; } - if (payload.cardNumber !== undefined) { - payload.cardNumber = payload.cardNumber ? String(payload.cardNumber).trim() : undefined; + if (updateData.isActive !== undefined) { + professor.isActive = Boolean(updateData.isActive); } - 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; + + // Update User personal details + if (professor.user) { + const user = await User.findById(professor.user._id || professor.user); + if (user) { + if (updateData.name || updateData.surname) { + const first = updateData.name !== undefined ? String(updateData.name).trim() : ''; + const last = updateData.surname !== undefined ? String(updateData.surname).trim() : ''; + user.name = `${first} ${last}`.trim() || user.name; + } + if (updateData.phoneNumber || updateData.phone) { + user.phoneNumber = String(updateData.phoneNumber || updateData.phone).trim(); + } + if (updateData.nationalIdCode || updateData.nationalId) { + user.nationalIdCode = String(updateData.nationalIdCode || updateData.nationalId).trim(); + } + if (updateData.email !== undefined) { + user.email = updateData.email ? String(updateData.email).trim().toLowerCase() : undefined; + } + if (updateData.cardNumber !== undefined) { + user.cardNumber = updateData.cardNumber ? String(updateData.cardNumber).trim() : undefined; + } + if (updateData.shabaNumber !== undefined || updateData.iban !== undefined) { + const val = updateData.shabaNumber || updateData.iban; + user.shabaNumber = val ? String(val).trim() : undefined; + } + await user.save(); + } } - Object.assign(professor, payload); await professor.save(); - return professor; + const populated = await Professor.findById(professor._id).populate('user').populate('courses', 'title type price'); + return formatProfessorDoc(populated); }; const deleteProfessor = async (id) => { @@ -223,6 +381,265 @@ const searchProfessors = async (queryParams) => { return getAllProfessors(queryParams); }; +/** + * Get comprehensive portal data for an authenticated professor (used in gameno-front teaching portal) + */ +const getProfessorPortalData = async (userId) => { + if (!userId) { + throw new AppError('AUTHENTICATION_FAILED'); + } + + const user = await User.findById(userId).populate({ path: 'role', select: 'name' }).lean(); + if (!user) { + throw new AppError('USER_NOT_FOUND'); + } + + let professor = await Professor.findOne({ user: userId }) + .populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber') + .populate('courses', 'title type price hoursPerSection sectionCount'); + + // Auto-link professor record if user holds Professor role + if (!professor) { + if (user.role?.name === 'Professor') { + professor = await Professor.create({ user: userId, isActive: true }); + professor = await Professor.findById(professor._id) + .populate('user', 'name nationalIdCode phoneNumber email cardNumber shabaNumber') + .populate('courses', 'title type price hoursPerSection sectionCount'); + } else { + throw new AppError('FORBIDDEN', null, 'شما دسترسی به پنل اساتید را ندارید'); + } + } + + const professorId = professor._id; + + // 1. Fetch all classes taught by this professor + const classes = await Class.find({ professor: professorId, isDeleted: { $ne: true } }) + .populate({ path: 'course', select: 'title type price hoursPerSection sectionCount description' }) + .populate({ path: 'students', select: 'name phoneNumber nationalIdCode' }) + .sort({ createdAt: -1 }) + .lean(); + + const classIds = classes.map((c) => c._id); + + // 2. Fetch all sessions for these classes + const sessions = classIds.length + ? await Session.find({ class: { $in: classIds }, isDeleted: { $ne: true } }) + .populate('class', 'name startDate') + .populate('course', 'title') + .sort({ day: 1, startTime: 1 }) + .lean() + : []; + + // 3. Fetch all payments for these classes to calculate actual class revenue + const payments = classIds.length + ? await Payment.find({ classes: { $in: classIds }, isDeleted: { $ne: true } }) + .select('classes amount discount paidAmount') + .lean() + : []; + + const revenueByClass = new Map(classIds.map((id) => [String(id), { expected: 0, received: 0 }])); + for (const p of payments) { + const payable = Math.max(0, (p.amount || 0) - (p.discount || 0)); + const paid = Math.max(0, Number(p.paidAmount) || 0); + for (const cRef of p.classes || []) { + const key = String(cRef); + if (revenueByClass.has(key)) { + const cur = revenueByClass.get(key); + cur.expected += payable; + cur.received += paid; + } + } + } + + // Group sessions by class + const sessionsByClass = new Map(classIds.map((id) => [String(id), { held: 0, scheduled: 0, cancelled: 0, all: [] }])); + const now = new Date(); + const upcomingSessionsList = []; + const heldSessionsList = []; + + for (const s of sessions) { + const key = String(s.class?._id || s.class); + const entry = sessionsByClass.get(key); + if (entry) { + entry.all.push(s); + if (s.status === 'held') entry.held += 1; + else if (s.status === 'cancelled') entry.cancelled += 1; + else entry.scheduled += 1; + } + + const sessionDate = s.day ? new Date(s.day) : null; + if (s.status === 'held') { + heldSessionsList.push(s); + } else if (sessionDate && sessionDate >= now) { + upcomingSessionsList.push(s); + } + } + + let totalEarnedPayout = 0; + let totalPendingPayout = 0; + let totalSessionsPlanned = 0; + let totalSessionsHeld = 0; + const distinctStudentIds = new Set(); + + const classCards = classes.map((cls) => { + const key = String(cls._id); + const rev = revenueByClass.get(key) || { expected: 0, received: 0 }; + const sess = sessionsByClass.get(key) || { held: 0, scheduled: 0, cancelled: 0, all: [] }; + const students = cls.students || []; + students.forEach((st) => distinctStudentIds.add(String(st._id || st))); + + const plannedSessions = cls.numberOfSessions ?? (cls.course?.sectionCount || sess.all.length || 1); + totalSessionsPlanned += plannedSessions; + totalSessionsHeld += sess.held; + + const durationHours = resolveSessionDurationHours(cls); + + // Calculate actual payout earned so far (from held sessions or received revenue) + const earnedPayout = calculateProfessorPayout({ + payoutType: cls.payoutType, + payoutPercentage: cls.payoutPercentage, + payoutHourlyRate: cls.payoutHourlyRate, + revenue: rev.received, + sessionDurationHours: durationHours, + sessionsCount: sess.held, + extraExpensePerSession: cls.extraExpensePerSession, + serviceFeePerPerson: cls.serviceFeePerPerson, + studentsCount: students.length + }); + + // Calculate potential total payout if all sessions held & expected revenue collected + const maxPotentialPayout = calculateProfessorPayout({ + payoutType: cls.payoutType, + payoutPercentage: cls.payoutPercentage, + payoutHourlyRate: cls.payoutHourlyRate, + revenue: rev.expected || (cls.tuitionFee * students.length), + sessionDurationHours: durationHours, + sessionsCount: plannedSessions, + extraExpensePerSession: cls.extraExpensePerSession, + serviceFeePerPerson: cls.serviceFeePerPerson, + studentsCount: students.length + }); + + totalEarnedPayout += earnedPayout.totalPayout; + totalPendingPayout += Math.max(0, maxPotentialPayout.totalPayout - earnedPayout.totalPayout); + + return { + _id: cls._id, + name: cls.name, + courseTitle: cls.course?.title || '—', + courseType: cls.course?.type || 'General', + startDate: cls.startDate, + endDate: cls.endDate, + days: cls.days || [], + startTime: cls.startTime || '', + endTime: cls.endTime || '', + isActive: cls.isActive !== false, + capacity: cls.capacity || 20, + studentsCount: students.length, + students: students.map((s) => ({ + _id: s._id, + name: s.name, + phoneNumber: s.phoneNumber + })), + sessionsPlanned: plannedSessions, + sessionsHeld: sess.held, + sessionsScheduled: sess.scheduled, + sessionsCancelled: sess.cancelled, + payoutType: cls.payoutType || 'percentage', + payoutPercentage: cls.payoutPercentage || 0, + payoutHourlyRate: cls.payoutHourlyRate || 0, + extraExpensePerSession: cls.extraExpensePerSession || 0, + serviceFeePerPerson: cls.serviceFeePerPerson || 0, + sessionDurationHours: durationHours, + earnedPayout: earnedPayout.totalPayout, + baseShare: earnedPayout.baseShare, + extraExpenses: earnedPayout.extraExpenses, + maxPotentialPayout: maxPotentialPayout.totalPayout, + expectedRevenue: rev.expected, + receivedRevenue: rev.received + }; + }); + + // Monthly earnings breakdown (by held session dates) + const monthlyTimelineMap = new Map(); + for (const s of heldSessionsList) { + if (!s.day) continue; + const d = new Date(s.day); + const monthKey = d.toISOString().slice(0, 7); // YYYY-MM + const cls = classes.find((c) => String(c._id) === String(s.class?._id || s.class)); + if (!cls) continue; + + const duration = resolveSessionDurationHours(cls); + let sessionPayout = 0; + if (cls.payoutType === 'hourly') { + sessionPayout = calculateHourlyShare({ + payoutHourlyRate: cls.payoutHourlyRate, + sessionDurationHours: duration, + sessionsCount: 1 + }) + (cls.extraExpensePerSession || 0); + } else { + const classRev = revenueByClass.get(String(cls._id))?.received || 0; + const planned = cls.numberOfSessions || 10; + sessionPayout = (calculatePercentageShare({ + payoutPercentage: cls.payoutPercentage, + revenue: classRev / planned, + serviceFeePerPerson: (cls.serviceFeePerPerson || 0) / planned, + studentsCount: (cls.students || []).length + })) + (cls.extraExpensePerSession || 0); + } + + if (!monthlyTimelineMap.has(monthKey)) { + monthlyTimelineMap.set(monthKey, { + monthKey, + monthLabel: monthKey, + amount: 0, + sessionsCount: 0 + }); + } + const entry = monthlyTimelineMap.get(monthKey); + entry.amount += Math.round(sessionPayout); + entry.sessionsCount += 1; + } + + const monthlyEarnings = Array.from(monthlyTimelineMap.values()).sort((a, b) => a.monthKey.localeCompare(b.monthKey)); + + return { + professor: formatProfessorDoc(professor), + stats: { + totalClasses: classes.length, + activeClasses: classes.filter((c) => c.isActive !== false).length, + totalStudents: distinctStudentIds.size, + totalSessionsPlanned, + totalSessionsHeld, + totalEarnedPayout: Math.round(totalEarnedPayout), + totalPendingPayout: Math.round(totalPendingPayout) + }, + classes: classCards, + upcomingSessions: upcomingSessionsList.slice(0, 15).map((s) => ({ + _id: s._id, + day: s.day, + startTime: s.startTime, + endTime: s.endTime, + topic: s.topic, + place: s.place, + className: s.class?.name || '—', + courseTitle: s.course?.title || '—' + })), + recentSessions: heldSessionsList.slice(-10).reverse().map((s) => ({ + _id: s._id, + day: s.day, + startTime: s.startTime, + endTime: s.endTime, + topic: s.topic, + place: s.place, + className: s.class?.name || '—', + courseTitle: s.course?.title || '—', + attendanceCount: (s.attendanceList || []).filter((a) => a.status === 'present').length + })), + monthlyEarnings + }; +}; + module.exports = { createProfessor, createProfessorFromUser, @@ -230,5 +647,7 @@ module.exports = { getAllProfessors, updateProfessor, deleteProfessor, - searchProfessors + searchProfessors, + getProfessorPortalData, + formatProfessorDoc }; diff --git a/components/professors/professorUserMerge.test.js b/components/professors/professorUserMerge.test.js new file mode 100644 index 0000000..eb0fb61 --- /dev/null +++ b/components/professors/professorUserMerge.test.js @@ -0,0 +1,60 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const mongoose = require('mongoose'); +const Professor = require('./professorModel'); +const { formatProfessorDoc } = require('./professorService'); + +test('Professor Model & User Merge Tests', async (t) => { + await t.test('Professor schema has user reference and virtual getters', () => { + const userId = new mongoose.Types.ObjectId(); + const prof = new Professor({ + user: userId, + bio: 'استاد ارشد هوش مصنوعی', + expertise: ['Machine Learning', 'Python'], + isActive: true + }); + + assert.equal(String(prof.user), String(userId)); + assert.equal(prof.bio, 'استاد ارشد هوش مصنوعی'); + assert.deepEqual(prof.expertise, ['Machine Learning', 'Python']); + assert.equal(prof.isActive, true); + }); + + await t.test('formatProfessorDoc correctly extracts and formats linked user fields', () => { + const userId = new mongoose.Types.ObjectId(); + const profId = new mongoose.Types.ObjectId(); + const mockDoc = { + _id: profId, + user: { + _id: userId, + name: 'علی حسینی', + nationalIdCode: '0012345678', + phoneNumber: '09123456789', + email: 'ali@example.com', + cardNumber: '6037997112345678', + shabaNumber: 'IR120000000000000000000000', + isActive: true + }, + bio: 'استاد برنامه‌نویسی', + expertise: ['Web', 'Vue'], + courses: [], + isActive: true + }; + + const formatted = formatProfessorDoc(mockDoc); + assert.equal(String(formatted._id), String(profId)); + assert.equal(String(formatted.user), String(userId)); + assert.equal(formatted.name, 'علی'); + assert.equal(formatted.surname, 'حسینی'); + assert.equal(formatted.fullName, 'علی حسینی'); + assert.equal(formatted.nationalIdCode, '0012345678'); + assert.equal(formatted.phoneNumber, '09123456789'); + assert.equal(formatted.email, 'ali@example.com'); + assert.equal(formatted.cardNumber, '6037997112345678'); + assert.equal(formatted.shabaNumber, 'IR120000000000000000000000'); + assert.deepEqual(formatted.expertise, ['Web', 'Vue']); + assert.equal(formatted.isActive, true); + }); +}); diff --git a/components/settings/messagingFlags.js b/components/settings/messagingFlags.js index 98cd93f..357b515 100644 --- a/components/settings/messagingFlags.js +++ b/components/settings/messagingFlags.js @@ -13,17 +13,32 @@ let cache = null; let cacheAt = 0; const CACHE_TTL_MS = 10000; +const normalizePhoneForBypass = (raw = '') => { + if (!raw) return ''; + let digits = String(raw) + .replace(/[۰-۹]/g, (d) => '0123456789'['۰۱۲۳۴۵۶۷۸۹'.indexOf(d)]) + .replace(/[٠-٩]/g, (d) => '0123456789'['٠١٢٣٤٥٦٧٨٩'.indexOf(d)]) + .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}`; + if (digits.length > 11 && digits.startsWith('09')) digits = digits.slice(0, 11); + return digits; +}; + const readDbFlags = (doc) => ({ smsEnabled: doc?.smsEnabled, emailEnabled: doc?.emailEnabled, - botEnabled: doc?.botEnabled + botEnabled: doc?.botEnabled, + smsBypassNumbers: Array.isArray(doc?.smsBypassNumbers) + ? doc.smsBypassNumbers.map((item) => (item.toObject ? item.toObject() : item)) + : [] }); const getDbMessagingFlags = async () => { const now = Date.now(); if (cache && (now - cacheAt) < CACHE_TTL_MS) return cache; const doc = await Setting.findOne({ key: SETTINGS_KEY }) - .select('smsEnabled emailEnabled botEnabled') + .select('smsEnabled emailEnabled botEnabled smsBypassNumbers') .lean(); cache = readDbFlags(doc); cacheAt = now; @@ -35,13 +50,42 @@ const invalidateMessagingCache = () => { cacheAt = 0; }; -const getMessagingChannelState = async (channel) => { +const isSmsBypassNumber = async (phoneNumber) => { + if (!phoneNumber) return false; + const target = normalizePhoneForBypass(phoneNumber); + if (!target) return false; + + try { + const db = await getDbMessagingFlags(); + const bypassList = Array.isArray(db.smsBypassNumbers) ? db.smsBypassNumbers : []; + return bypassList.some((item) => item.isActive !== false && normalizePhoneForBypass(item.phoneNumber) === target); + } catch (err) { + logger.warn(`[Messaging] Failed to check bypass numbers: ${err.message}`); + return false; + } +}; + +const getMessagingChannelState = async (channel, recipientPhone = null) => { const envEnabled = isEnvChannelEnabled(channel, config); + const bypass = channel === 'sms' && recipientPhone ? await isSmsBypassNumber(recipientPhone) : false; + + if (bypass) { + logger.info(`[Messaging] Recipient ${recipientPhone} is in SMS bypass list. Channel bypass activated.`); + return { + enabled: true, + envEnabled, + dbEnabled: true, + bypass: true, + blockReason: null + }; + } + if (!envEnabled) { return { enabled: false, envEnabled: false, dbEnabled: null, + bypass: false, blockReason: 'env_disabled' }; } @@ -53,6 +97,7 @@ const getMessagingChannelState = async (channel) => { enabled: dbEnabled, envEnabled: true, dbEnabled, + bypass: false, blockReason: dbEnabled ? null : 'dashboard_disabled' }; } catch (err) { @@ -61,13 +106,14 @@ const getMessagingChannelState = async (channel) => { enabled: true, envEnabled: true, dbEnabled: null, + bypass: false, blockReason: null }; } }; -const isMessagingChannelEnabled = async (channel) => { - const state = await getMessagingChannelState(channel); +const isMessagingChannelEnabled = async (channel, recipientPhone = null) => { + const state = await getMessagingChannelState(channel, recipientPhone); return state.enabled; }; @@ -76,6 +122,8 @@ const getPublicMessaging = (doc) => toPublicMessaging(readDbFlags(doc), config); module.exports = { getMessagingChannelState, isMessagingChannelEnabled, + isSmsBypassNumber, + normalizePhoneForBypass, invalidateMessagingCache, getPublicMessaging, getDbMessagingFlags diff --git a/components/settings/settingModel.js b/components/settings/settingModel.js index 0c8cd56..90e29e6 100644 --- a/components/settings/settingModel.js +++ b/components/settings/settingModel.js @@ -4,6 +4,29 @@ const mongoose = require('mongoose'); const SETTINGS_KEY = 'app'; +const bypassNumberSchema = new mongoose.Schema({ + phoneNumber: { + type: String, + required: true, + trim: true + }, + label: { + type: String, + trim: true, + default: '' + }, + isActive: { + type: Boolean, + default: true + }, + createdAt: { + type: Date, + default: Date.now + } +}, { + _id: true +}); + const settingSchema = new mongoose.Schema({ key: { type: String, @@ -28,6 +51,10 @@ const settingSchema = new mongoose.Schema({ type: Boolean, default: true }, + smsBypassNumbers: { + type: [bypassNumberSchema], + default: [] + }, notificationSettings: { type: mongoose.Schema.Types.Mixed, default: {} diff --git a/components/settings/settingService.js b/components/settings/settingService.js index b0f8a9f..36d7c4e 100644 --- a/components/settings/settingService.js +++ b/components/settings/settingService.js @@ -13,7 +13,8 @@ const { const { parseIncomingMessaging } = require('../../utils/messagingChannels'); const { getPublicMessaging, - invalidateMessagingCache + invalidateMessagingCache, + normalizePhoneForBypass } = require('./messagingFlags'); const { emptyNotificationSettingsMap, @@ -82,13 +83,28 @@ const toPublicTemplates = (storedMap, notificationMap = {}) => { }); }; +const formatBypassNumbers = (list) => { + if (!Array.isArray(list)) return []; + return list.map((item) => { + const raw = item.toObject ? item.toObject() : item; + return { + _id: raw._id, + phoneNumber: raw.phoneNumber, + label: raw.label || '', + isActive: raw.isActive !== false, + createdAt: raw.createdAt + }; + }); +}; + const getSettings = async () => { let doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); if (!doc) { const created = await Setting.create({ key: SETTINGS_KEY, smsTemplates: emptyTemplateMap(), - notificationSettings: emptyNotificationSettingsMap() + notificationSettings: emptyNotificationSettingsMap(), + smsBypassNumbers: [] }); doc = created.toObject(); } else { @@ -110,7 +126,8 @@ const getSettings = async () => { return { smsTemplates: toPublicTemplates(storedMap, notificationMap), messaging: getPublicMessaging(doc), - notificationSettings: await getPublicNotificationSettings(doc) + notificationSettings: await getPublicNotificationSettings(doc), + smsBypassNumbers: formatBypassNumbers(doc.smsBypassNumbers) }; }; @@ -250,6 +267,26 @@ const saveSettings = async (body = {}) => { doc.set('notificationSettings', emptyNotificationSettingsMap()); } + // Handle smsBypassNumbers + if (Array.isArray(body.smsBypassNumbers)) { + const nextBypass = body.smsBypassNumbers + .map((item) => { + const rawPhone = item.phoneNumber || item.phone; + const normalized = normalizePhoneForBypass(rawPhone); + return { + _id: item._id || undefined, + phoneNumber: normalized || rawPhone, + label: String(item.label || '').trim(), + isActive: item.isActive !== false, + createdAt: item.createdAt || new Date() + }; + }) + .filter((item) => item.phoneNumber && item.phoneNumber.length >= 10); + + doc.set('smsBypassNumbers', nextBypass); + doc.markModified('smsBypassNumbers'); + } + await doc.save(); invalidateMessagingCache(); invalidateNotificationSettingsCache(); @@ -259,7 +296,8 @@ const saveSettings = async (body = {}) => { return { smsTemplates: toPublicTemplates(readStoredMap(saved), finalNotificationMap), messaging: getPublicMessaging(saved), - notificationSettings: await getPublicNotificationSettings(saved) + notificationSettings: await getPublicNotificationSettings(saved), + smsBypassNumbers: formatBypassNumbers(saved.smsBypassNumbers) }; }; diff --git a/components/settings/smsBypass.test.js b/components/settings/smsBypass.test.js new file mode 100644 index 0000000..5768690 --- /dev/null +++ b/components/settings/smsBypass.test.js @@ -0,0 +1,37 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { normalizePhoneForBypass } = require('./messagingFlags'); + +test('SMS Bypass Numbers Utilities', async (t) => { + await t.test('normalizePhoneForBypass handles various phone formats and Persian digits', () => { + assert.equal(normalizePhoneForBypass('۰۹۱۲۳۴۵۶۷۸۹'), '09123456789'); + assert.equal(normalizePhoneForBypass('+989123456789'), '09123456789'); + assert.equal(normalizePhoneForBypass('989123456789'), '09123456789'); + assert.equal(normalizePhoneForBypass('9123456789'), '09123456789'); + assert.equal(normalizePhoneForBypass('0912-345-6789'), '09123456789'); + assert.equal(normalizePhoneForBypass(''), ''); + assert.equal(normalizePhoneForBypass(null), ''); + }); + + await t.test('bypass list matches normalized phone numbers correctly', () => { + const bypassList = [ + { phoneNumber: '09123456789', label: 'مدیریت', isActive: true }, + { phoneNumber: '09351112233', label: 'پشتیبان غیرفعال', isActive: false } + ]; + + const isMatch = (targetPhone) => { + const normalized = normalizePhoneForBypass(targetPhone); + return bypassList.some( + (item) => item.isActive !== false && normalizePhoneForBypass(item.phoneNumber) === normalized + ); + }; + + assert.equal(isMatch('09123456789'), true); + assert.equal(isMatch('+989123456789'), true); + assert.equal(isMatch('۰۹۱۲۳۴۵۶۷۸۹'), true); + assert.equal(isMatch('09351112233'), false); // Inactive + assert.equal(isMatch('09100000000'), false); // Not in list + }); +}); diff --git a/package.json b/package.json index d1533a4..82c56f5 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "node app.js", "dev": "nodemon app.js", "seed": "node seed.js", - "test": "node --test components/employeeTimings/employeeTiming.test.js components/settings/smsTemplateRender.test.js components/waitlist/waitlistService.test.js components/payments/bulkPayment.test.js components/classes/classSoftDelete.test.js components/settings/smsTemplates.test.js components/users/passwordReset.test.js components/users/studentProfileAndPromotion.test.js components/dataImport/importHelpers.test.js utils/classSchedule.test.js utils/jalaliDate.test.js utils/messagingChannels.test.js utils/notifyFlags.test.js utils/passwordRules.test.js utils/paymentAmount.test.js utils/professorShare.test.js utils/financialRange.test.js" + "test": "node --test components/employeeTimings/employeeTiming.test.js components/settings/smsTemplateRender.test.js components/waitlist/waitlistService.test.js components/payments/bulkPayment.test.js components/classes/classSoftDelete.test.js components/settings/smsTemplates.test.js components/users/passwordReset.test.js components/users/studentProfileAndPromotion.test.js components/dataImport/importHelpers.test.js components/professors/professorUserMerge.test.js components/settings/smsBypass.test.js utils/classSchedule.test.js utils/jalaliDate.test.js utils/messagingChannels.test.js utils/notifyFlags.test.js utils/passwordRules.test.js utils/paymentAmount.test.js utils/professorShare.test.js utils/financialRange.test.js" }, "keywords": [ "express", diff --git a/utils/actionNotify.js b/utils/actionNotify.js index 6d045f0..ebe905e 100644 --- a/utils/actionNotify.js +++ b/utils/actionNotify.js @@ -5,6 +5,7 @@ const { sendEmail } = require('./senders/emailSender'); const { sendBaleMessage } = require('./senders/baleBotSender'); const { resolveNotifyFlags } = require('./notifyResolver'); const { findActionDef } = require('../components/settings/notificationActions'); +const { isSmsBypassNumber } = require('../components/settings/messagingFlags'); /** * Send SMS (via handler), email, and bot notifications for an action when enabled. @@ -26,7 +27,8 @@ const notifyAction = async ({ const eventTag = relatedEvent || def?.relatedEvent || actionKey; const results = {}; - if (flags.sms && phoneNumber && typeof smsHandler === 'function') { + const isBypass = phoneNumber ? await isSmsBypassNumber(phoneNumber) : false; + if ((flags.sms || isBypass) && phoneNumber && typeof smsHandler === 'function') { results.sms = await smsHandler(); } diff --git a/utils/senders/sms.base.js b/utils/senders/sms.base.js index 8c1c898..074354d 100644 --- a/utils/senders/sms.base.js +++ b/utils/senders/sms.base.js @@ -22,7 +22,7 @@ const redactSmsParams = (params = []) => ( ); const sendSingleSms = async (mobile, templateId, params = []) => { - const channelState = await getMessagingChannelState('sms'); + const channelState = await getMessagingChannelState('sms', mobile); logger.info('[SMS] About to send notification:', { mobile, @@ -30,6 +30,7 @@ const sendSingleSms = async (mobile, templateId, params = []) => { params: redactSmsParams(params), SMS_ENABLED: channelState.envEnabled, dashboardEnabled: channelState.dbEnabled, + bypassActive: Boolean(channelState.bypass) }); if (!channelState.enabled) { diff --git a/utils/senders/smsMessages.js b/utils/senders/smsMessages.js index 04463b0..49db5e9 100644 --- a/utils/senders/smsMessages.js +++ b/utils/senders/smsMessages.js @@ -5,6 +5,7 @@ const { sendSingleSms } = require('./sms.base'); const { recordAndSend } = require('./notificationRecorder'); const { getSmsTemplate } = require('../../components/settings/settingService'); const { buildSmsParameters, renderSmsText } = require('../../components/settings/smsTemplates'); +const { isSmsBypassNumber } = require('../../components/settings/messagingFlags'); const User = require('../../components/users/userModel'); const logger = require('../logger'); @@ -34,7 +35,8 @@ const sendTemplateSms = async ({ }) => { const resolvedUserId = userId || await resolveUserIdByPhone(receiver); const { templateId, variables, enabled, text: templateText } = await getSmsTemplate(templateKey); - if (enabled === false) { + const isBypass = await isSmsBypassNumber(receiver); + if (enabled === false && !isBypass) { logger.info(`[SMS] Template ${templateKey} is disabled. Skipping SMS send to ${receiver}.`); return { success: true, skipped: true, reason: 'template_disabled' }; }