Compare commits
4
Commits
dca7250be0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a022c0917 | ||
|
|
b708a6b777 | ||
|
|
38e048067b | ||
|
|
4d711fe39c |
+6
-6
@@ -7,9 +7,9 @@ FROM node:22-alpine AS dependencies
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY package*.json .npmrc* ./
|
||||
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
RUN npm ci --omit=dev --registry=https://registry.npmmirror.com && npm cache clean --force
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Stage 2: Production runtime image
|
||||
@@ -19,7 +19,7 @@ FROM node:22-alpine AS production
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000
|
||||
PORT=5000
|
||||
|
||||
# Copy installed production dependencies and application code with proper ownership
|
||||
COPY --chown=node:node --from=dependencies /app/node_modules ./node_modules
|
||||
@@ -28,10 +28,10 @@ COPY --chown=node:node . .
|
||||
# Run as non-root user
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 5000
|
||||
|
||||
# Healthcheck targeting the API health endpoint
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:${PORT:-5000}/api/health || exit 1
|
||||
|
||||
CMD ["node", "app.js"]
|
||||
|
||||
@@ -142,8 +142,8 @@ const startServer = async () => {
|
||||
startTempBucketCleanupJob();
|
||||
startClassReminderJob();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Gameno API listening on http://localhost:${PORT} [${config.NODE_ENV}]`);
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
logger.info(`Gameno API listening on http://0.0.0.0:${PORT} [${config.NODE_ENV}]`);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
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;
|
||||
await user.save();
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user