Compare commits

..
14 Commits
Author SHA1 Message Date
kavehhn 0a022c0917 feat(professors,sms): merge professor with users, add existing user link, professor portal endpoint, and SMS bypass list 2026-08-25 03:48:19 +03:30
kavehhn b708a6b777 fix(healthcheck): use dynamic PORT, IPv4 127.0.0.1, and bind 0.0.0.0 2026-08-25 02:56:42 +03:30
kavehhn 38e048067b fix(docker): copy .npmrc and use npmmirror registry for fast builds 2026-08-25 02:43:34 +03:30
kavehhn 4d711fe39c build: add .npmrc with liara npm mirror registry 2026-08-25 01:58:16 +03:30
kavehhn dca7250be0 feat(users,professors): add promote student to professor and full student profile aggregation 2026-08-24 22:52:38 +03:30
kavehhn 282dd42422 feat: add employee timing system, notification templates, and professor class plan sms 2026-08-24 19:31:07 +03:30
kavehhn 53fce86ad5 feat: add waitlist module, quick payment/transaction edit, and catering fee deduction from professor share 2026-08-23 23:00:02 +03:30
kavehhn e3b51a1629 feat(financial-reports): add institute-wide analytics endpoint
Adds GET /financial-reports/admin/analytics returning daily/weekly income
trends (cash received vs. accrued session income), a multi-month
received/payout/expense/profit breakdown, a per-class income-per-session
leaderboard, payment-status distribution, and a due-date driven cash-flow
forecast (overdue + upcoming installments) to power richer charts on the
dashboard's financial reports page.
2026-08-23 18:24:32 +03:30
kavehhn 3cc813f24e fix(payments): enhance duplicate payment check query and auto-populate course 2026-08-23 16:55:48 +03:30
kavehhn dde0efc72a fix(sessions): respect database sort order in getAllSessions and getMySessions 2026-08-23 16:41:49 +03:30
kavehhn 22ce6c3fb1 fix(payments,dates): improve duplicate payment detection and Jalali date parsing 2026-08-23 14:15:06 +03:30
kavehhn 945b4e6332 fix(payments): ensure ObjectId casting for duplicate check query 2026-08-23 13:35:30 +03:30
kavehhn 28d401fd0e feat(payments): add bulk class payments creation and duplicate check 2026-08-23 13:28:06 +03:30
kavehhn 4c07ca2ae1 fix: format prices in notifications, change partial payment label, and add session holding notify API 2026-08-21 20:17:30 +03:30
61 changed files with 3954 additions and 362 deletions
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com/
+6 -6
View File
@@ -7,9 +7,9 @@ FROM node:22-alpine AS dependencies
WORKDIR /app 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 # Stage 2: Production runtime image
@@ -19,7 +19,7 @@ FROM node:22-alpine AS production
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
PORT=3000 PORT=5000
# Copy installed production dependencies and application code with proper ownership # Copy installed production dependencies and application code with proper ownership
COPY --chown=node:node --from=dependencies /app/node_modules ./node_modules COPY --chown=node:node --from=dependencies /app/node_modules ./node_modules
@@ -28,10 +28,10 @@ COPY --chown=node:node . .
# Run as non-root user # Run as non-root user
USER node USER node
EXPOSE 3000 EXPOSE 5000
# Healthcheck targeting the API health endpoint # Healthcheck targeting the API health endpoint
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:${PORT:-5000}/api/health || exit 1
CMD ["node", "app.js"] CMD ["node", "app.js"]
+6 -2
View File
@@ -103,7 +103,11 @@ app.use('/api/contact-inquiries', contactInquiryRoutes);
app.use('/api/expenses', expenseRoutes); app.use('/api/expenses', expenseRoutes);
app.use('/api/financial-reports', financialReportRoutes); app.use('/api/financial-reports', financialReportRoutes);
const pendingStudentRoutes = require('./components/pendingStudents/pendingStudentRoutes'); const pendingStudentRoutes = require('./components/pendingStudents/pendingStudentRoutes');
const waitlistRoutes = require('./components/waitlist/waitlistRoutes');
app.use('/api/pending-students', pendingStudentRoutes); app.use('/api/pending-students', pendingStudentRoutes);
app.use('/api/waitlist', waitlistRoutes);
const employeeTimingRoutes = require('./components/employeeTimings/employeeTimingRoutes');
app.use('/api/employee-timings', employeeTimingRoutes);
app.use('/api/seed', seedRoutes); app.use('/api/seed', seedRoutes);
app.use('/api/data-import', dataImportRoutes); app.use('/api/data-import', dataImportRoutes);
app.use('/api/settings', settingRoutes); app.use('/api/settings', settingRoutes);
@@ -138,8 +142,8 @@ const startServer = async () => {
startTempBucketCleanupJob(); startTempBucketCleanupJob();
startClassReminderJob(); startClassReminderJob();
app.listen(PORT, () => { app.listen(PORT, '0.0.0.0', () => {
logger.info(`Gameno API listening on http://localhost:${PORT} [${config.NODE_ENV}]`); logger.info(`Gameno API listening on http://0.0.0.0:${PORT} [${config.NODE_ENV}]`);
}); });
}; };
+5
View File
@@ -59,3 +59,8 @@ exports.getPublicOne = catchAsync(async (req, res) => {
const cls = await classService.getPublicOne(req.params.id); const cls = await classService.getPublicOne(req.params.id);
return successResponse(res, 200, 'Class retrieved successfully', cls); return successResponse(res, 200, 'Class retrieved successfully', cls);
}); });
exports.sendClassPlanToProfessor = catchAsync(async (req, res) => {
const result = await classService.sendClassPlanToProfessor(req.params.id);
return successResponse(res, 200, 'برنامه کلاس با موفقیت برای استاد ارسال شد.', result);
});
+6
View File
@@ -104,6 +104,12 @@ const classSchema = new mongoose.Schema({
default: 0, default: 0,
min: 0 min: 0
}, },
/** Catering / service fee per person deducted from tuition before percentage payout */
serviceFeePerPerson: {
type: Number,
default: 0,
min: 0
},
isActive: { isActive: {
type: Boolean, type: Boolean,
default: true default: true
+1
View File
@@ -27,5 +27,6 @@ router.delete('/admin/delete/:id', perm.requires([PERMISSIONS.CLASSES_DELETE, PE
router.post('/admin/restore/:id', perm.requires([PERMISSIONS.CLASSES_DELETE, PERMISSIONS.CLASSES_UPDATE]), classController.restore); router.post('/admin/restore/:id', perm.requires([PERMISSIONS.CLASSES_DELETE, PERMISSIONS.CLASSES_UPDATE]), classController.restore);
router.post('/admin/:id/register-users', perm.requires(PERMISSIONS.CLASSES_REGISTER_USERS), classController.registerUsers); router.post('/admin/:id/register-users', perm.requires(PERMISSIONS.CLASSES_REGISTER_USERS), classController.registerUsers);
router.delete('/admin/:id/students/:userId', perm.requires(PERMISSIONS.CLASSES_REGISTER_USERS), classController.removeUser); router.delete('/admin/:id/students/:userId', perm.requires(PERMISSIONS.CLASSES_REGISTER_USERS), classController.removeUser);
router.post('/admin/:id/send-plan-professor', perm.requires(PERMISSIONS.CLASSES_UPDATE), classController.sendClassPlanToProfessor);
module.exports = router; module.exports = router;
+71 -5
View File
@@ -8,8 +8,9 @@ const PendingStudent = require('../pendingStudents/pendingStudentModel');
const Payment = require('../payments/paymentModel'); const Payment = require('../payments/paymentModel');
const AppError = require('../../utils/AppError'); const AppError = require('../../utils/AppError');
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages'); const { sendClassRegisteredSms, sendClassPlanProfessorSms } = require('../../utils/senders/smsMessages');
const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime, calculateClassEndDate } = require('../../utils/classSchedule'); const { buildClassScheduleContext, normalizeWeekdays, normalizeClockTime, calculateClassEndDate, formatClassDaysFromIndexes } = require('../../utils/classSchedule');
const { formatJalaliDate } = require('../../utils/jalaliDate');
const { resolveNotifyFlags } = require('../../utils/notifyResolver'); const { resolveNotifyFlags } = require('../../utils/notifyResolver');
const { notifyAction } = require('../../utils/actionNotify'); const { notifyAction } = require('../../utils/actionNotify');
const logger = require('../../utils/logger'); const logger = require('../../utils/logger');
@@ -32,7 +33,7 @@ const applyScheduleFields = (payload, body) => {
return payload; return payload;
}; };
const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime numberOfSessions payoutType payoutPercentage payoutHourlyRate extraExpensePerSession isActive isDeleted deletedAt adminNotes createdAt updatedAt'; const CLASS_LIST_FIELDS = 'name course professor students capacity tuitionFee hasDiscount discount freeSpots showOnFrontend startDate endDate days startTime endTime numberOfSessions payoutType payoutPercentage payoutHourlyRate extraExpensePerSession serviceFeePerPerson isActive isDeleted deletedAt adminNotes createdAt updatedAt';
const normalizePricingFields = (body = {}) => { const normalizePricingFields = (body = {}) => {
const tuitionFee = Math.max(0, Number(body.tuitionFee) || 0); const tuitionFee = Math.max(0, Number(body.tuitionFee) || 0);
@@ -48,7 +49,8 @@ const normalizePayoutFields = (body = {}) => {
const payoutPercentage = Math.min(100, Math.max(0, Number(body.payoutPercentage) || 0)); const payoutPercentage = Math.min(100, Math.max(0, Number(body.payoutPercentage) || 0));
const payoutHourlyRate = Math.max(0, Number(body.payoutHourlyRate) || 0); const payoutHourlyRate = Math.max(0, Number(body.payoutHourlyRate) || 0);
const extraExpensePerSession = Math.max(0, Number(body.extraExpensePerSession) || 0); const extraExpensePerSession = Math.max(0, Number(body.extraExpensePerSession) || 0);
return { payoutType, payoutPercentage, payoutHourlyRate, extraExpensePerSession }; const serviceFeePerPerson = Math.max(0, Number(body.serviceFeePerPerson) || 0);
return { payoutType, payoutPercentage, payoutHourlyRate, extraExpensePerSession, serviceFeePerPerson };
}; };
const normalizeNumberOfSessions = (value) => { const normalizeNumberOfSessions = (value) => {
@@ -293,4 +295,68 @@ const getPublicOne = async (id) => {
return enrichClassForDisplay(cls); return enrichClassForDisplay(cls);
}; };
module.exports = { getAll, getOne, create, update, remove, restore, registerUsers, removeUser, getMyClasses, getPublicClasses, getPublicOne }; const sendClassPlanToProfessor = async (classId) => {
const classDoc = await Class.findById(classId)
.populate('course', 'title')
.populate('professor', 'name surname phoneNumber');
if (!classDoc) {
throw new AppError('CLASS_NOT_FOUND', null, 'کلاس یافت نشد.');
}
if (!classDoc.professor) {
throw new AppError('VALIDATION_FAILED', { field: 'professor' }, 'برای این کلاس هیچ استادی تعیین نشده است.');
}
const professor = classDoc.professor;
if (!professor.phoneNumber) {
throw new AppError('VALIDATION_FAILED', { field: 'phoneNumber' }, 'شماره همراه استاد ثبت نشده است.');
}
const profName = `${professor.name || ''} ${professor.surname || ''}`.trim() || 'استاد';
const className = classDoc.name || classDoc.course?.title || 'کلاس';
const classDays = formatClassDaysFromIndexes(classDoc.days) || 'طبق هماهنگی';
const classTimes = (classDoc.startTime && classDoc.endTime)
? `${classDoc.startTime} الی ${classDoc.endTime}`
: (classDoc.startTime || classDoc.endTime || 'طبق هماهنگی');
const classStartDate = classDoc.startDate ? formatJalaliDate(classDoc.startDate) : '';
const classEndDate = classDoc.endDate ? formatJalaliDate(classDoc.endDate) : '';
const slotValues = {
professorName: profName,
className,
classDays,
classTimes,
classStartDate,
classEndDate,
phoneNumber: professor.phoneNumber
};
const result = await sendClassPlanProfessorSms(professor.phoneNumber, slotValues, professor._id);
return {
success: true,
result,
recipient: {
name: profName,
phoneNumber: professor.phoneNumber
},
slotValues
};
};
module.exports = {
getAll,
getOne,
create,
update,
remove,
restore,
registerUsers,
removeUser,
getMyClasses,
getPublicClasses,
getPublicOne,
sendClassPlanToProfessor
};
+25 -39
View File
@@ -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; const targetRole = professorRole || userRole;
let user = await findExistingUser({ nationalIdCode, phoneNumber, name: fullName }); let user = await findExistingUser({ nationalIdCode, phoneNumber, name: fullName });
if (user) { if (user) {
@@ -529,7 +491,7 @@ const upsertProfessorAndUser = async (profInput, professorRole, userRole, stats,
if (cardNumber && !user.cardNumber) user.cardNumber = cardNumber; if (cardNumber && !user.cardNumber) user.cardNumber = cardNumber;
if (shabaNumber && !user.shabaNumber) user.shabaNumber = shabaNumber; if (shabaNumber && !user.shabaNumber) user.shabaNumber = shabaNumber;
if (email && !user.email) user.email = email; 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; user.role = targetRole._id;
} }
await user.save(); await user.save();
@@ -555,6 +517,30 @@ const upsertProfessorAndUser = async (profInput, professorRole, userRole, stats,
stats.studentsCreated += 1; 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 }; return { professor: professorDoc, user };
}; };
@@ -0,0 +1,61 @@
// /components/employeeTimings/employeeTiming.test.js
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
computeStatus,
computeDurationMinutes,
computeDurationFormatted
} = require('./employeeTimingService');
describe('EmployeeTiming status and warning computations', () => {
it('returns "complete" when both entry and exit times are present', () => {
assert.equal(computeStatus('08:30', '17:00'), 'complete');
assert.equal(computeStatus('09:00', '14:30'), 'complete');
});
it('returns "missing_exit" when entry is present but exit is missing', () => {
assert.equal(computeStatus('08:30', ''), 'missing_exit');
assert.equal(computeStatus('08:30', null), 'missing_exit');
assert.equal(computeStatus('08:30', undefined), 'missing_exit');
assert.equal(computeStatus('08:30', ' '), 'missing_exit');
});
it('returns "missing_entry" when exit is present but entry is missing', () => {
assert.equal(computeStatus('', '17:00'), 'missing_entry');
assert.equal(computeStatus(null, '17:00'), 'missing_entry');
assert.equal(computeStatus(undefined, '17:00'), 'missing_entry');
assert.equal(computeStatus(' ', '17:00'), 'missing_entry');
});
it('returns "incomplete" when neither entry nor exit is present', () => {
assert.equal(computeStatus('', ''), 'incomplete');
assert.equal(computeStatus(null, null), 'incomplete');
assert.equal(computeStatus(undefined, undefined), 'incomplete');
});
});
describe('EmployeeTiming duration computations', () => {
it('calculates duration in minutes correctly for standard day hours', () => {
assert.equal(computeDurationMinutes('08:00', '16:30'), 510);
assert.equal(computeDurationMinutes('09:15', '10:45'), 90);
});
it('handles shift crossing midnight', () => {
assert.equal(computeDurationMinutes('22:00', '02:00'), 240);
});
it('returns null for missing times', () => {
assert.equal(computeDurationMinutes('', '17:00'), null);
assert.equal(computeDurationMinutes('08:00', ''), null);
assert.equal(computeDurationMinutes(null, null), null);
});
it('formats duration in Persian string', () => {
assert.equal(computeDurationFormatted(510), '8 ساعت و 30 دقیقه');
assert.equal(computeDurationFormatted(120), '2 ساعت');
assert.equal(computeDurationFormatted(45), '45 دقیقه');
assert.equal(computeDurationFormatted(null), '—');
});
});
@@ -0,0 +1,36 @@
// /components/employeeTimings/employeeTimingController.js
'use strict';
const catchAsync = require('../../utils/catchAsync');
const employeeTimingService = require('./employeeTimingService');
const { successResponse } = require('../../utils/apiResponse');
exports.getAll = catchAsync(async (req, res) => {
const result = await employeeTimingService.getAll(req.query);
return successResponse(res, 200, 'Employee timing records retrieved', result.items, result.meta);
});
exports.getSummary = catchAsync(async (req, res) => {
const summary = await employeeTimingService.getSummary(req.query);
return successResponse(res, 200, 'Employee timing summary retrieved', summary);
});
exports.getOne = catchAsync(async (req, res) => {
const record = await employeeTimingService.getOne(req.params.id);
return successResponse(res, 200, 'Employee timing record retrieved', record);
});
exports.create = catchAsync(async (req, res) => {
const record = await employeeTimingService.create(req.body, req.user?._id);
return successResponse(res, 201, 'Employee timing record created', record);
});
exports.update = catchAsync(async (req, res) => {
const record = await employeeTimingService.update(req.params.id, req.body, req.user?._id);
return successResponse(res, 200, 'Employee timing record updated', record);
});
exports.delete = catchAsync(async (req, res) => {
const result = await employeeTimingService.remove(req.params.id);
return successResponse(res, 200, 'Employee timing record deleted', result);
});
@@ -0,0 +1,120 @@
// /components/employeeTimings/employeeTimingModel.js
'use strict';
const mongoose = require('mongoose');
const employeeTimingSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
date: {
type: Date,
required: true,
default: Date.now,
index: true
},
entryTime: {
type: String,
trim: true,
default: ''
},
exitTime: {
type: String,
trim: true,
default: ''
},
note: {
type: String,
trim: true,
default: ''
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
updatedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
/**
* Status virtual property:
* - 'complete': both entryTime and exitTime are provided
* - 'missing_exit': entryTime provided, but exitTime missing (Warning)
* - 'missing_entry': exitTime provided, but entryTime missing (Warning)
* - 'incomplete': both entryTime and exitTime are missing (Warning)
*/
employeeTimingSchema.virtual('status').get(function () {
const hasEntry = Boolean(this.entryTime && String(this.entryTime).trim());
const hasExit = Boolean(this.exitTime && String(this.exitTime).trim());
if (hasEntry && hasExit) return 'complete';
if (hasEntry && !hasExit) return 'missing_exit';
if (!hasEntry && hasExit) return 'missing_entry';
return 'incomplete';
});
/**
* Warning flag virtual property:
* True if record is incomplete (missing entry, missing exit, or both)
*/
employeeTimingSchema.virtual('hasWarning').get(function () {
const hasEntry = Boolean(this.entryTime && String(this.entryTime).trim());
const hasExit = Boolean(this.exitTime && String(this.exitTime).trim());
return !(hasEntry && hasExit);
});
/**
* Warning message helper
*/
employeeTimingSchema.virtual('warningMessage').get(function () {
const hasEntry = Boolean(this.entryTime && String(this.entryTime).trim());
const hasExit = Boolean(this.exitTime && String(this.exitTime).trim());
if (hasEntry && !hasExit) return 'ورود ثبت شده ولی خروج ثبت نشده است';
if (!hasEntry && hasExit) return 'خروج ثبت شده ولی ورود ثبت نشده است';
if (!hasEntry && !hasExit) return 'زمان ورود و خروج هیچ‌کدام ثبت نشده است';
return null;
});
/**
* Duration in minutes calculation (if both entry and exit times are valid HH:mm)
*/
employeeTimingSchema.virtual('durationMinutes').get(function () {
if (!this.entryTime || !this.exitTime) return null;
const [inH, inM] = String(this.entryTime).split(':').map(Number);
const [outH, outM] = String(this.exitTime).split(':').map(Number);
if (Number.isNaN(inH) || Number.isNaN(inM) || Number.isNaN(outH) || Number.isNaN(outM)) {
return null;
}
const startMinutes = inH * 60 + inM;
let endMinutes = outH * 60 + outM;
if (endMinutes < startMinutes) {
// Crosses midnight
endMinutes += 24 * 60;
}
return endMinutes - startMinutes;
});
/**
* Formatted duration in Persian/hours
*/
employeeTimingSchema.virtual('durationFormatted').get(function () {
const minutes = this.durationMinutes;
if (minutes === null || minutes === undefined) return '—';
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m} دقیقه`;
if (m === 0) return `${h} ساعت`;
return `${h} ساعت و ${m} دقیقه`;
});
module.exports = mongoose.model('EmployeeTiming', employeeTimingSchema);
@@ -0,0 +1,21 @@
// /components/employeeTimings/employeeTimingRoutes.js
'use strict';
const express = require('express');
const employeeTimingController = require('./employeeTimingController');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
router.get('/admin/get-all', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_READ), employeeTimingController.getAll);
router.get('/admin/summary', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_READ), employeeTimingController.getSummary);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_READ), employeeTimingController.getOne);
router.post('/admin/create', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_CREATE), employeeTimingController.create);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_UPDATE), employeeTimingController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.EMPLOYEE_TIMINGS_DELETE), employeeTimingController.delete);
module.exports = router;
@@ -0,0 +1,327 @@
// /components/employeeTimings/employeeTimingService.js
'use strict';
const EmployeeTiming = require('./employeeTimingModel');
const User = require('../users/userModel');
const AppError = require('../../utils/AppError');
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
const normalizeTime = (timeStr) => {
if (timeStr === undefined || timeStr === null) return '';
const trimmed = String(timeStr).trim();
if (!trimmed) return '';
// Check HH:mm format
const match = /^([01]?[0-9]|2[0-3]):([0-5][0-9])$/.exec(trimmed);
if (match) {
const hh = match[1].padStart(2, '0');
const mm = match[2];
return `${hh}:${mm}`;
}
return trimmed;
};
const normalizeDate = (rawDate) => {
if (!rawDate) return new Date();
const d = new Date(rawDate);
if (Number.isNaN(d.getTime())) {
throw new AppError('VALIDATION_FAILED', { field: 'date' }, 'تاریخ نامعتبر است');
}
return d;
};
const sanitizeNote = (note) => {
if (!note) return '';
return String(note).trim().slice(0, 1000);
};
const computeStatus = (entryTime, exitTime) => {
const hasEntry = Boolean(entryTime && String(entryTime).trim());
const hasExit = Boolean(exitTime && String(exitTime).trim());
if (hasEntry && hasExit) return 'complete';
if (hasEntry && !hasExit) return 'missing_exit';
if (!hasEntry && hasExit) return 'missing_entry';
return 'incomplete';
};
const computeDurationMinutes = (entryTime, exitTime) => {
if (!entryTime || !exitTime) return null;
const [inH, inM] = String(entryTime).split(':').map(Number);
const [outH, outM] = String(exitTime).split(':').map(Number);
if (Number.isNaN(inH) || Number.isNaN(inM) || Number.isNaN(outH) || Number.isNaN(outM)) {
return null;
}
const startMinutes = inH * 60 + inM;
let endMinutes = outH * 60 + outM;
if (endMinutes < startMinutes) {
endMinutes += 24 * 60;
}
return endMinutes - startMinutes;
};
const computeDurationFormatted = (minutes) => {
if (minutes === null || minutes === undefined) return '—';
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m} دقیقه`;
if (m === 0) return `${h} ساعت`;
return `${h} ساعت و ${m} دقیقه`;
};
const enrichRecord = (record) => {
const obj = record.toObject ? record.toObject({ virtuals: true }) : { ...record };
const entryTime = obj.entryTime || '';
const exitTime = obj.exitTime || '';
const status = computeStatus(entryTime, exitTime);
const hasWarning = status !== 'complete';
const durationMinutes = computeDurationMinutes(entryTime, exitTime);
const durationFormatted = computeDurationFormatted(durationMinutes);
let warningMessage = null;
if (status === 'missing_exit') warningMessage = 'ورود ثبت شده ولی خروج ثبت نشده است';
else if (status === 'missing_entry') warningMessage = 'خروج ثبت شده ولی ورود ثبت نشده است';
else if (status === 'incomplete') warningMessage = 'زمان ورود و خروج هیچ‌کدام ثبت نشده است';
return {
...obj,
status,
hasWarning,
warningMessage,
durationMinutes,
durationFormatted
};
};
const getAll = async (query = {}) => {
const page = parseInt(query.page, 10) || 1;
const limit = Math.min(parseInt(query.limit, 10) || 20, 200);
const skip = (page - 1) * limit;
const filter = {};
if (query.user || query.userId) {
filter.user = query.user || query.userId;
}
// Date range filter
if (query.startDate || query.endDate) {
filter.date = {};
if (query.startDate) {
const start = new Date(query.startDate);
start.setHours(0, 0, 0, 0);
filter.date.$gte = start;
}
if (query.endDate) {
const end = new Date(query.endDate);
end.setHours(23, 59, 59, 999);
filter.date.$lte = end;
}
}
// Search by user name / username / nationalIdCode
const searchTerm = getSearchTerm(query);
if (searchTerm) {
const escaped = escapeRegex(searchTerm);
const matchingUsers = await User.find({
$or: [
{ name: new RegExp(escaped, 'i') },
{ username: new RegExp(escaped, 'i') },
{ nationalIdCode: new RegExp(escaped, 'i') },
{ phoneNumber: new RegExp(escaped, 'i') }
]
}).select('_id').lean();
const userIds = matchingUsers.map(u => u._id);
if (filter.user) {
if (!userIds.some(id => String(id) === String(filter.user))) {
return {
items: [],
meta: calculateMeta(0, page, limit)
};
}
} else {
filter.user = { $in: userIds };
}
}
// Filter by status (complete, incomplete, missing_exit, missing_entry)
if (query.status) {
const s = String(query.status).trim();
if (s === 'complete') {
filter.entryTime = { $nin: ['', null] };
filter.exitTime = { $nin: ['', null] };
} else if (s === 'missing_exit') {
filter.entryTime = { $nin: ['', null] };
filter.$or = [{ exitTime: '' }, { exitTime: null }, { exitTime: { $exists: false } }];
} else if (s === 'missing_entry') {
filter.exitTime = { $nin: ['', null] };
filter.$or = [{ entryTime: '' }, { entryTime: null }, { entryTime: { $exists: false } }];
} else if (s === 'incomplete') {
filter.$or = [
{ entryTime: '' },
{ entryTime: null },
{ entryTime: { $exists: false } },
{ exitTime: '' },
{ exitTime: null },
{ exitTime: { $exists: false } }
];
}
}
const [records, total] = await Promise.all([
EmployeeTiming.find(filter)
.populate('user', 'name nationalIdCode phoneNumber username role uniqueCode')
.populate('createdBy', 'name username')
.populate('updatedBy', 'name username')
.sort({ date: -1, createdAt: -1 })
.skip(skip)
.limit(limit),
EmployeeTiming.countDocuments(filter)
]);
return {
items: records.map(enrichRecord),
meta: calculateMeta(total, page, limit)
};
};
const getSummary = async (query = {}) => {
const baseFilter = {};
if (query.user || query.userId) {
baseFilter.user = query.user || query.userId;
}
if (query.startDate || query.endDate) {
baseFilter.date = {};
if (query.startDate) {
const start = new Date(query.startDate);
start.setHours(0, 0, 0, 0);
baseFilter.date.$gte = start;
}
if (query.endDate) {
const end = new Date(query.endDate);
end.setHours(23, 59, 59, 999);
baseFilter.date.$lte = end;
}
}
const [total, complete, missingExit, missingEntry] = await Promise.all([
EmployeeTiming.countDocuments(baseFilter),
EmployeeTiming.countDocuments({
...baseFilter,
entryTime: { $nin: ['', null] },
exitTime: { $nin: ['', null] }
}),
EmployeeTiming.countDocuments({
...baseFilter,
entryTime: { $nin: ['', null] },
$or: [{ exitTime: '' }, { exitTime: null }, { exitTime: { $exists: false } }]
}),
EmployeeTiming.countDocuments({
...baseFilter,
exitTime: { $nin: ['', null] },
$or: [{ entryTime: '' }, { entryTime: null }, { entryTime: { $exists: false } }]
})
]);
const incomplete = total - complete;
return {
total,
complete,
incomplete,
missingExit,
missingEntry
};
};
const getOne = async (id) => {
const record = await EmployeeTiming.findById(id)
.populate('user', 'name nationalIdCode phoneNumber username role uniqueCode')
.populate('createdBy', 'name username')
.populate('updatedBy', 'name username');
if (!record) {
throw new AppError('NOT_FOUND', { resource: 'EmployeeTiming' }, 'رکورد تردد یافت نشد');
}
return enrichRecord(record);
};
const create = async (body, authorId) => {
if (!body.user && !body.userId) {
throw new AppError('VALIDATION_FAILED', { field: 'user' }, 'انتخاب کارمند / کاربر الزامی است');
}
const userId = body.user || body.userId;
const userExists = await User.findById(userId).select('_id name').lean();
if (!userExists) {
throw new AppError('NOT_FOUND', { resource: 'User' }, 'کاربر انتخاب‌شده یافت نشد');
}
const record = await EmployeeTiming.create({
user: userId,
date: normalizeDate(body.date),
entryTime: normalizeTime(body.entryTime),
exitTime: normalizeTime(body.exitTime),
note: sanitizeNote(body.note),
createdBy: authorId || undefined,
updatedBy: authorId || undefined
});
return getOne(record._id);
};
const update = async (id, body, authorId) => {
const record = await EmployeeTiming.findById(id);
if (!record) {
throw new AppError('NOT_FOUND', { resource: 'EmployeeTiming' }, 'رکورد تردد یافت نشد');
}
if (body.user !== undefined || body.userId !== undefined) {
const targetUserId = body.user || body.userId;
const userExists = await User.findById(targetUserId).select('_id').lean();
if (!userExists) {
throw new AppError('NOT_FOUND', { resource: 'User' }, 'کاربر انتخاب‌شده یافت نشد');
}
record.user = targetUserId;
}
if (body.date !== undefined) {
record.date = normalizeDate(body.date);
}
if (body.entryTime !== undefined) {
record.entryTime = normalizeTime(body.entryTime);
}
if (body.exitTime !== undefined) {
record.exitTime = normalizeTime(body.exitTime);
}
if (body.note !== undefined) {
record.note = sanitizeNote(body.note);
}
if (authorId) {
record.updatedBy = authorId;
}
await record.save();
return getOne(record._id);
};
const remove = async (id) => {
const record = await EmployeeTiming.findByIdAndDelete(id);
if (!record) {
throw new AppError('NOT_FOUND', { resource: 'EmployeeTiming' }, 'رکورد تردد یافت نشد');
}
return { success: true };
};
module.exports = {
getAll,
getSummary,
getOne,
create,
update,
remove,
computeStatus,
computeDurationMinutes,
computeDurationFormatted
};
@@ -19,3 +19,8 @@ exports.getRangeReport = catchAsync(async (req, res) => {
const report = await financialReportService.getRangeReport(req.query); const report = await financialReportService.getRangeReport(req.query);
return successResponse(res, 200, 'Date-range financial report retrieved successfully', report); return successResponse(res, 200, 'Date-range financial report retrieved successfully', report);
}); });
exports.getAnalytics = catchAsync(async (req, res) => {
const report = await financialReportService.getAnalytics(req.query);
return successResponse(res, 200, 'Financial analytics retrieved successfully', report);
});
@@ -12,6 +12,7 @@ const router = express.Router();
router.use(authMiddleware); router.use(authMiddleware);
router.use(perm.requires(PERMISSIONS.FINANCIAL_REPORTS_READ)); router.use(perm.requires(PERMISSIONS.FINANCIAL_REPORTS_READ));
router.get('/admin/analytics', financialReportController.getAnalytics);
router.get('/admin/range', financialReportController.getRangeReport); router.get('/admin/range', financialReportController.getRangeReport);
router.get('/admin/classes/:classId', financialReportController.getClassReport); router.get('/admin/classes/:classId', financialReportController.getClassReport);
router.get('/admin/sessions/:sessionId', financialReportController.getSessionReport); router.get('/admin/sessions/:sessionId', financialReportController.getSessionReport);
@@ -5,8 +5,17 @@ const Class = require('../classes/classModel');
const Session = require('../sessions/sessionModel'); const Session = require('../sessions/sessionModel');
const Payment = require('../payments/paymentModel'); const Payment = require('../payments/paymentModel');
const Transaction = require('../payments/transactionModel'); const Transaction = require('../payments/transactionModel');
const Expense = require('../expenses/expenseModel');
const AppError = require('../../utils/AppError'); const AppError = require('../../utils/AppError');
const { resolveDateRange, isWithinRange } = require('../../utils/financialRange'); const { resolveDateRange, isWithinRange } = require('../../utils/financialRange');
const {
startOfDay,
buildDailyBuckets,
buildWeeklyBuckets,
buildMonthlyBuckets,
buildForecastWeeklyBuckets,
findBucketIndex
} = require('../../utils/reportBuckets');
const { const {
getPayableAmount, getPayableAmount,
isPaidTransaction, isPaidTransaction,
@@ -94,7 +103,9 @@ const getClassReport = async (classId) => {
revenue: revenue.actualReceivedRevenue, revenue: revenue.actualReceivedRevenue,
sessionDurationHours, sessionDurationHours,
sessionsCount: sessionCounts.held, sessionsCount: sessionCounts.held,
extraExpensePerSession: cls.extraExpensePerSession extraExpensePerSession: cls.extraExpensePerSession,
serviceFeePerPerson: cls.serviceFeePerPerson,
studentsCount: (cls.students || []).length
}); });
const netProfit = calculateNetProfit({ const netProfit = calculateNetProfit({
@@ -176,7 +187,9 @@ const getSessionReport = async (sessionId) => {
revenue: sessionIncome, revenue: sessionIncome,
payoutHourlyRate: cls.payoutHourlyRate, payoutHourlyRate: cls.payoutHourlyRate,
sessionDurationHours, sessionDurationHours,
sessionsCount: 1 sessionsCount: 1,
serviceFeePerPerson: (cls.serviceFeePerPerson || 0) / (plannedSessions || 1),
studentsCount
}); });
const sessionExtraExpense = calculateExtraExpenses({ const sessionExtraExpense = calculateExtraExpenses({
extraExpensePerSession: cls.extraExpensePerSession, extraExpensePerSession: cls.extraExpensePerSession,
@@ -299,7 +312,9 @@ const getRangeReport = async (query = {}) => {
revenue: receivedInRange, revenue: receivedInRange,
sessionDurationHours, sessionDurationHours,
sessionsCount: sessionsHeldInRange, sessionsCount: sessionsHeldInRange,
extraExpensePerSession: cls.extraExpensePerSession extraExpensePerSession: cls.extraExpensePerSession,
serviceFeePerPerson: cls.serviceFeePerPerson,
studentsCount: (cls.students || []).length
}); });
return { return {
@@ -335,8 +350,473 @@ const getRangeReport = async (query = {}) => {
}; };
}; };
const clampInt = (value, fallback, min, max) => {
const n = parseInt(value, 10);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
};
const buildStudentName = (user) => {
if (!user || typeof user !== 'object') return '—';
return user.name || '—';
};
const emptyDaily = (buckets) => buckets.map((b) => ({ date: b.key, received: 0, sessionsHeld: 0, sessionIncome: 0 }));
const emptyWeekly = (buckets) => buckets.map((b) => ({
weekStart: b.key,
weekEnd: b.end.toISOString().slice(0, 10),
received: 0,
outstanding: 0,
sessionsHeld: 0,
sessionIncome: 0
}));
const emptyMonthly = (buckets) => buckets.map((b) => ({
year: b.year,
month: b.month,
monthStart: b.start,
received: 0,
outstanding: 0,
sessionsHeld: 0,
professorPayouts: 0,
generalExpenses: 0,
netProfit: 0
}));
const emptyForecastBuckets = (buckets) => buckets.map((b) => ({
weekStart: b.key,
weekEnd: b.end.toISOString().slice(0, 10),
expectedAmount: 0,
count: 0
}));
/**
* Institute-wide financial analytics: daily & weekly income trends (cash received vs.
* accrued session income), a multi-month received/payout/expense/profit breakdown,
* per-class income leaderboard, payment-status distribution, and a forward-looking
* cash-flow forecast built from outstanding transactions' due dates.
*/
const getAnalytics = async (query = {}) => {
const now = new Date();
const dailyDays = clampInt(query.dailyDays, 30, 7, 90);
const weeklyWeeks = clampInt(query.weeklyWeeks, 12, 4, 26);
const monthlyMonths = clampInt(query.monthlyMonths, 6, 3, 24);
const forecastWeeks = clampInt(query.forecastWeeks, 8, 4, 16);
const dailyBuckets = buildDailyBuckets(dailyDays, now);
const weeklyBuckets = buildWeeklyBuckets(weeklyWeeks, now);
const monthlyBuckets = buildMonthlyBuckets(monthlyMonths, now);
const forecastBuckets = buildForecastWeeklyBuckets(forecastWeeks, now);
const earliestWindowStart = [dailyBuckets[0]?.start, weeklyBuckets[0]?.start, monthlyBuckets[0]?.start]
.filter(Boolean)
.reduce((min, d) => (d.getTime() < min.getTime() ? d : min), now);
const classes = await Class.find({ isDeleted: { $ne: true } })
.populate({ path: 'course', select: 'title hoursPerSection' })
.populate({ path: 'professor', select: 'name surname' })
.lean();
const classIds = classes.map((c) => c._id);
if (!classIds.length) {
const generalExpensesAllTime = await sumExpensesInRange(new Date(0), now);
return {
generatedAt: now,
daily: emptyDaily(dailyBuckets),
weekly: emptyWeekly(weeklyBuckets),
monthly: emptyMonthly(monthlyBuckets),
overview: {
totalExpectedRevenue: 0,
totalReceived: 0,
totalPendingReceivables: 0,
overdueAmount: 0,
overdueCount: 0,
totalProfessorPayoutsAllTime: 0,
totalGeneralExpensesAllTime: generalExpensesAllTime,
netProfitAllTime: -generalExpensesAllTime,
totalEnrollments: 0,
totalActiveClasses: 0,
totalClasses: 0,
totalSessionsHeld: 0,
totalSessionsPlanned: 0,
avgIncomePerHeldSession: 0,
avgExpectedIncomePerPlannedSession: 0
},
incomeByClass: [],
paymentStatusBreakdown: [],
upcomingDue: [],
forecast: {
overdueAmount: 0,
overdueCount: 0,
next7DaysAmount: 0,
next30DaysAmount: 0,
next60DaysAmount: 0,
buckets: emptyForecastBuckets(forecastBuckets)
}
};
}
const [revenueByClass, sessions, payments] = await Promise.all([
getRevenueByClass(classIds),
Session.find({ class: { $in: classIds }, isDeleted: { $ne: true } }).select('class day status').lean(),
Payment.find({ classes: { $in: classIds }, isDeleted: { $ne: true } })
.select('classes user')
.populate('user', 'name')
.lean()
]);
const paymentClassByPaymentId = new Map();
const paymentUserByPaymentId = new Map();
const paymentIds = [];
for (const payment of payments) {
paymentIds.push(payment._id);
const matchedClassId = (payment.classes || []).map(normalizeId).find((id) => classIds.some((c) => String(c) === id));
paymentClassByPaymentId.set(String(payment._id), matchedClassId);
paymentUserByPaymentId.set(String(payment._id), payment.user);
}
const [transactions, expenses, generalExpensesAllTime, paymentsFull] = await Promise.all([
paymentIds.length
? Transaction.find({ payment: { $in: paymentIds } }).select('payment amount status date dueDate').lean()
: [],
Expense.find({ date: { $gte: startOfDay(earliestWindowStart) } }).select('date amount').lean(),
sumExpensesInRange(new Date(0), now),
Payment.find({ isDeleted: { $ne: true } }).select('status amount discount').lean()
]);
const sessionCountsByClass = new Map(classIds.map((id) => [String(id), { total: 0, held: 0 }]));
for (const s of sessions) {
const key = String(s.class);
const entry = sessionCountsByClass.get(key);
if (!entry) continue;
entry.total += 1;
if (s.status === 'held') entry.held += 1;
}
// ── Per-class financial profile (used throughout every bucket + the leaderboard) ──
const classProfiles = new Map();
for (const cls of classes) {
const key = String(cls._id);
const revenue = revenueByClass.get(key) || { expectedRevenue: 0, actualReceivedRevenue: 0 };
const sessionCounts = sessionCountsByClass.get(key) || { total: 0, held: 0 };
const studentsCount = (cls.students || []).length;
const plannedSessions = cls.numberOfSessions ?? sessionCounts.total;
const finalTuitionFee = cls.hasDiscount ? Math.max(0, (cls.tuitionFee || 0) - (cls.discount || 0)) : (cls.tuitionFee || 0);
const assignedTuitionPerStudent = studentsCount > 0 && revenue.expectedRevenue > 0
? revenue.expectedRevenue / studentsCount
: finalTuitionFee;
const perSessionIncomeEstimate = calculateStudentRevenuePerSession(assignedTuitionPerStudent, plannedSessions) * studentsCount;
classProfiles.set(key, {
id: cls._id,
name: cls.name,
professorName: cls.professor ? `${cls.professor.name || ''} ${cls.professor.surname || ''}`.trim() || '—' : '—',
courseName: (cls.course && cls.course.title) || '—',
isActive: cls.isActive !== false,
payoutType: cls.payoutType,
payoutPercentage: cls.payoutPercentage,
payoutHourlyRate: cls.payoutHourlyRate,
extraExpensePerSession: cls.extraExpensePerSession,
serviceFeePerPerson: cls.serviceFeePerPerson,
sessionDurationHours: resolveSessionDurationHours(cls),
studentsCount,
plannedSessions,
heldSessions: sessionCounts.held,
expectedRevenue: revenue.expectedRevenue,
actualReceivedRevenue: revenue.actualReceivedRevenue,
perSessionIncomeEstimate
});
}
// ── Daily / weekly / monthly buckets ──────────────────────────────────────
const dailyReceived = new Array(dailyBuckets.length).fill(0);
const dailyHeld = new Array(dailyBuckets.length).fill(0);
const dailySessionIncome = new Array(dailyBuckets.length).fill(0);
const weeklyReceived = new Array(weeklyBuckets.length).fill(0);
const weeklyOutstanding = new Array(weeklyBuckets.length).fill(0);
const weeklyHeld = new Array(weeklyBuckets.length).fill(0);
const weeklySessionIncome = new Array(weeklyBuckets.length).fill(0);
const monthlyReceived = new Array(monthlyBuckets.length).fill(0);
const monthlyOutstanding = new Array(monthlyBuckets.length).fill(0);
const monthlyHeld = new Array(monthlyBuckets.length).fill(0);
const monthlyExpenses = new Array(monthlyBuckets.length).fill(0);
const monthlyClassReceived = monthlyBuckets.map(() => new Map());
const monthlyClassHeld = monthlyBuckets.map(() => new Map());
for (const s of sessions) {
if (s.status !== 'held' || !s.day) continue;
const classKey = String(s.class);
const profile = classProfiles.get(classKey);
const perSessionIncome = profile ? profile.perSessionIncomeEstimate : 0;
const di = findBucketIndex(dailyBuckets, s.day);
if (di >= 0) {
dailyHeld[di] += 1;
dailySessionIncome[di] += perSessionIncome;
}
const wi = findBucketIndex(weeklyBuckets, s.day);
if (wi >= 0) {
weeklyHeld[wi] += 1;
weeklySessionIncome[wi] += perSessionIncome;
}
const mi = findBucketIndex(monthlyBuckets, s.day);
if (mi >= 0) {
monthlyHeld[mi] += 1;
const map = monthlyClassHeld[mi];
map.set(classKey, (map.get(classKey) || 0) + 1);
}
}
for (const trx of transactions) {
const classKey = paymentClassByPaymentId.get(String(trx.payment));
const paid = isPaidTransaction(trx);
const active = isActiveTransaction(trx);
const amount = Number(trx.amount) || 0;
if (paid && trx.date) {
const di = findBucketIndex(dailyBuckets, trx.date);
if (di >= 0) dailyReceived[di] += amount;
const wi = findBucketIndex(weeklyBuckets, trx.date);
if (wi >= 0) weeklyReceived[wi] += amount;
const mi = findBucketIndex(monthlyBuckets, trx.date);
if (mi >= 0) {
monthlyReceived[mi] += amount;
if (classKey) {
const map = monthlyClassReceived[mi];
map.set(classKey, (map.get(classKey) || 0) + amount);
}
}
} else if (active && !paid && trx.dueDate) {
const wi = findBucketIndex(weeklyBuckets, trx.dueDate);
if (wi >= 0) weeklyOutstanding[wi] += amount;
const mi = findBucketIndex(monthlyBuckets, trx.dueDate);
if (mi >= 0) monthlyOutstanding[mi] += amount;
}
}
for (const exp of expenses) {
const mi = findBucketIndex(monthlyBuckets, exp.date);
if (mi >= 0) monthlyExpenses[mi] += Number(exp.amount) || 0;
}
const monthlyProfessorPayouts = monthlyBuckets.map((_, mi) => {
let total = 0;
for (const [classKey, profile] of classProfiles.entries()) {
const received = monthlyClassReceived[mi].get(classKey) || 0;
const held = monthlyClassHeld[mi].get(classKey) || 0;
if (!received && !held) continue;
const payout = calculateProfessorPayout({
payoutType: profile.payoutType,
payoutPercentage: profile.payoutPercentage,
payoutHourlyRate: profile.payoutHourlyRate,
revenue: received,
sessionDurationHours: profile.sessionDurationHours,
sessionsCount: held,
extraExpensePerSession: profile.extraExpensePerSession,
serviceFeePerPerson: profile.serviceFeePerPerson,
studentsCount: profile.studentsCount
});
total += payout.totalPayout;
}
return total;
});
const daily = dailyBuckets.map((b, i) => ({
date: b.key,
received: dailyReceived[i],
sessionsHeld: dailyHeld[i],
sessionIncome: dailySessionIncome[i]
}));
const weekly = weeklyBuckets.map((b, i) => ({
weekStart: b.key,
weekEnd: b.end.toISOString().slice(0, 10),
received: weeklyReceived[i],
outstanding: weeklyOutstanding[i],
sessionsHeld: weeklyHeld[i],
sessionIncome: weeklySessionIncome[i]
}));
const monthly = monthlyBuckets.map((b, i) => ({
year: b.year,
month: b.month,
monthStart: b.start,
received: monthlyReceived[i],
outstanding: monthlyOutstanding[i],
sessionsHeld: monthlyHeld[i],
professorPayouts: monthlyProfessorPayouts[i],
generalExpenses: monthlyExpenses[i],
netProfit: monthlyReceived[i] - monthlyProfessorPayouts[i] - monthlyExpenses[i]
}));
// ── All-time overview + per-class leaderboard ─────────────────────────────
let totalExpectedRevenue = 0;
let totalReceived = 0;
let totalProfessorPayoutsAllTime = 0;
let totalSessionsPlanned = 0;
let totalSessionsHeldAllTime = 0;
let totalEnrollments = 0;
let totalActiveClasses = 0;
const incomeByClass = [];
for (const profile of classProfiles.values()) {
totalExpectedRevenue += profile.expectedRevenue;
totalReceived += profile.actualReceivedRevenue;
totalSessionsPlanned += profile.plannedSessions || 0;
totalSessionsHeldAllTime += profile.heldSessions;
totalEnrollments += profile.studentsCount;
if (profile.isActive) totalActiveClasses += 1;
const payout = calculateProfessorPayout({
payoutType: profile.payoutType,
payoutPercentage: profile.payoutPercentage,
payoutHourlyRate: profile.payoutHourlyRate,
revenue: profile.actualReceivedRevenue,
sessionDurationHours: profile.sessionDurationHours,
sessionsCount: profile.heldSessions,
extraExpensePerSession: profile.extraExpensePerSession,
serviceFeePerPerson: profile.serviceFeePerPerson,
studentsCount: profile.studentsCount
});
totalProfessorPayoutsAllTime += payout.totalPayout;
incomeByClass.push({
classId: profile.id,
className: profile.name,
professorName: profile.professorName,
courseName: profile.courseName,
studentsCount: profile.studentsCount,
expectedRevenue: profile.expectedRevenue,
actualReceivedRevenue: profile.actualReceivedRevenue,
pendingReceivables: calculatePendingReceivables(profile.expectedRevenue, profile.actualReceivedRevenue),
sessionsHeld: profile.heldSessions,
sessionsPlanned: profile.plannedSessions,
incomePerSessionActual: profile.heldSessions > 0 ? profile.actualReceivedRevenue / profile.heldSessions : 0,
incomePerSessionExpected: profile.plannedSessions > 0 ? profile.expectedRevenue / profile.plannedSessions : 0
});
}
incomeByClass.sort((a, b) => b.actualReceivedRevenue - a.actualReceivedRevenue);
const totalPendingReceivables = calculatePendingReceivables(totalExpectedRevenue, totalReceived);
const netProfitAllTime = totalReceived - totalProfessorPayoutsAllTime - generalExpensesAllTime;
let overdueAmount = 0;
let overdueCount = 0;
for (const trx of transactions) {
if (isActiveTransaction(trx) && !isPaidTransaction(trx) && trx.dueDate && new Date(trx.dueDate).getTime() < now.getTime()) {
overdueAmount += Number(trx.amount) || 0;
overdueCount += 1;
}
}
const statusMap = new Map();
for (const p of paymentsFull) {
const status = p.status || 'pending';
const entry = statusMap.get(status) || { status, count: 0, amount: 0 };
entry.count += 1;
entry.amount += getPayableAmount(p);
statusMap.set(status, entry);
}
const paymentStatusBreakdown = Array.from(statusMap.values());
// ── Forecast: upcoming due dates → expected cash inflow ───────────────────
const pendingTransactions = transactions.filter((t) => isActiveTransaction(t) && !isPaidTransaction(t) && t.dueDate);
const forecastBucketTotals = forecastBuckets.map(() => ({ amount: 0, count: 0 }));
const in7 = now.getTime() + 7 * 86400000;
const in30 = now.getTime() + 30 * 86400000;
const in60 = now.getTime() + 60 * 86400000;
let next7DaysAmount = 0;
let next30DaysAmount = 0;
let next60DaysAmount = 0;
for (const trx of pendingTransactions) {
const due = new Date(trx.dueDate);
const dueTime = due.getTime();
const amount = Number(trx.amount) || 0;
if (dueTime >= now.getTime()) {
if (dueTime <= in7) next7DaysAmount += amount;
if (dueTime <= in30) next30DaysAmount += amount;
if (dueTime <= in60) next60DaysAmount += amount;
}
const fi = findBucketIndex(forecastBuckets, due);
if (fi >= 0) {
forecastBucketTotals[fi].amount += amount;
forecastBucketTotals[fi].count += 1;
}
}
const upcomingDue = pendingTransactions
.slice()
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime())
.slice(0, 50)
.map((trx) => {
const classKey = paymentClassByPaymentId.get(String(trx.payment));
const profile = classProfiles.get(classKey);
const studentUser = paymentUserByPaymentId.get(String(trx.payment));
const due = new Date(trx.dueDate);
const daysUntilDue = Math.round((due.getTime() - now.getTime()) / 86400000);
return {
transactionId: trx._id,
studentName: buildStudentName(studentUser),
className: profile ? profile.name : '—',
amount: Number(trx.amount) || 0,
dueDate: trx.dueDate,
daysUntilDue,
isOverdue: daysUntilDue < 0
};
});
return {
generatedAt: now,
daily,
weekly,
monthly,
overview: {
totalExpectedRevenue,
totalReceived,
totalPendingReceivables,
overdueAmount,
overdueCount,
totalProfessorPayoutsAllTime,
totalGeneralExpensesAllTime: generalExpensesAllTime,
netProfitAllTime,
totalEnrollments,
totalActiveClasses,
totalClasses: classes.length,
totalSessionsHeld: totalSessionsHeldAllTime,
totalSessionsPlanned,
avgIncomePerHeldSession: totalSessionsHeldAllTime > 0 ? totalReceived / totalSessionsHeldAllTime : 0,
avgExpectedIncomePerPlannedSession: totalSessionsPlanned > 0 ? totalExpectedRevenue / totalSessionsPlanned : 0
},
incomeByClass,
paymentStatusBreakdown,
upcomingDue,
forecast: {
overdueAmount,
overdueCount,
next7DaysAmount,
next30DaysAmount,
next60DaysAmount,
buckets: forecastBuckets.map((b, i) => ({
weekStart: b.key,
weekEnd: b.end.toISOString().slice(0, 10),
expectedAmount: forecastBucketTotals[i].amount,
count: forecastBucketTotals[i].count
}))
}
};
};
module.exports = { module.exports = {
getClassReport, getClassReport,
getSessionReport, getSessionReport,
getRangeReport getRangeReport,
getAnalytics
}; };
+33
View File
@@ -0,0 +1,33 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const paymentService = require('./paymentService');
describe('Bulk Payment and Duplicate Check Service', () => {
it('exports createBulkClassPayments and checkDuplicatePayment', () => {
assert.equal(typeof paymentService.createBulkClassPayments, 'function');
assert.equal(typeof paymentService.checkDuplicatePayment, 'function');
});
it('checkDuplicatePayment returns false when userId is not provided', async () => {
const result = await paymentService.checkDuplicatePayment({});
assert.deepEqual(result, {
hasDuplicate: false,
count: 0,
payments: []
});
});
it('createBulkClassPayments throws error when classId is missing', async () => {
await assert.rejects(
async () => {
await paymentService.createBulkClassPayments({});
},
(err) => {
assert.equal(err.errorCode, 'VALIDATION_FAILED');
return true;
}
);
});
});
+22
View File
@@ -10,6 +10,17 @@ exports.create = catchAsync(async (req, res, next) => {
return successResponse(res, 201, 'Payment created successfully', payment); return successResponse(res, 201, 'Payment created successfully', payment);
}); });
exports.createBulkClass = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const result = await paymentService.createBulkClassPayments(req.body, actorId);
return successResponse(res, 201, 'Bulk payments created successfully', result);
});
exports.checkDuplicate = catchAsync(async (req, res, next) => {
const result = await paymentService.checkDuplicatePayment(req.query);
return successResponse(res, 200, 'Duplicate check completed', result);
});
exports.getOne = catchAsync(async (req, res, next) => { exports.getOne = catchAsync(async (req, res, next) => {
const payment = await paymentService.getPaymentById(req.params.id); const payment = await paymentService.getPaymentById(req.params.id);
return successResponse(res, 200, 'Payment retrieved successfully', payment); return successResponse(res, 200, 'Payment retrieved successfully', payment);
@@ -64,3 +75,14 @@ exports.cancelTransaction = catchAsync(async (req, res, next) => {
const payment = await paymentService.cancelTransaction(req.params.transactionId, actorId); const payment = await paymentService.cancelTransaction(req.params.transactionId, actorId);
return successResponse(res, 200, 'Transaction cancelled successfully', payment); return successResponse(res, 200, 'Transaction cancelled successfully', payment);
}); });
exports.revertTransaction = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const payment = await paymentService.revertTransaction(req.params.transactionId, actorId);
return successResponse(res, 200, 'Transaction reverted successfully', payment);
});
exports.deleteTransaction = catchAsync(async (req, res, next) => {
const payment = await paymentService.deleteTransaction(req.params.transactionId);
return successResponse(res, 200, 'Transaction deleted successfully', payment);
});
+10 -1
View File
@@ -42,9 +42,15 @@ const paymentSchema = new mongoose.Schema({
}, },
status: { status: {
type: String, type: String,
enum: ['pending', 'partial', 'paid', 'overdue'], enum: ['pending', 'partial', 'paid', 'overdue', 'cancelled', 'reverted'],
default: 'pending' default: 'pending'
}, },
type: {
type: String,
enum: ['regular', 'waiting_list'],
default: 'regular',
index: true
},
notes: { type: String, trim: true, maxlength: 5000 }, notes: { type: String, trim: true, maxlength: 5000 },
isDeleted: { isDeleted: {
type: Boolean, type: Boolean,
@@ -72,6 +78,9 @@ paymentSchema.virtual('transactions', {
paymentSchema.pre('save', function (next) { paymentSchema.pre('save', function (next) {
this.discount = normalizeDiscount(this.discount, this.amount); this.discount = normalizeDiscount(this.discount, this.amount);
const payable = getPayableAmount(this); const payable = getPayableAmount(this);
if (this.status === 'cancelled' || this.status === 'reverted') {
return next();
}
if (this.paidAmount >= payable) { if (this.paidAmount >= payable) {
this.status = 'paid'; this.status = 'paid';
} else if (this.paidAmount > 0) { } else if (this.paidAmount > 0) {
+4
View File
@@ -22,12 +22,16 @@ router.post('/user/pay/:id', validateAddTransaction, paymentController.payUser);
// Admin Scope // Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.PAYMENTS_CREATE), validateCreatePayment, paymentController.create); router.post('/admin/create', perm.requires(PERMISSIONS.PAYMENTS_CREATE), validateCreatePayment, paymentController.create);
router.post('/admin/bulk-class', perm.requires(PERMISSIONS.PAYMENTS_CREATE), paymentController.createBulkClass);
router.get('/admin/check-duplicate', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.checkDuplicate);
router.get('/admin/get-all', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getAll); router.get('/admin/get-all', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.PAYMENTS_SEARCH), paymentController.search); router.get('/admin/search', perm.requires(PERMISSIONS.PAYMENTS_SEARCH), paymentController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getOne); router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdatePayment, paymentController.update); router.put('/admin/update/:id', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdatePayment, paymentController.update);
router.put('/admin/transactions/:transactionId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdateTransaction, paymentController.updateTransaction); router.put('/admin/transactions/:transactionId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdateTransaction, paymentController.updateTransaction);
router.post('/admin/transactions/:transactionId/cancel', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), paymentController.cancelTransaction); router.post('/admin/transactions/:transactionId/cancel', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), paymentController.cancelTransaction);
router.post('/admin/transactions/:transactionId/revert', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), paymentController.revertTransaction);
router.delete('/admin/transactions/:transactionId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), paymentController.deleteTransaction);
router.post('/admin/transactions/:paymentId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateAddTransaction, paymentController.createTransaction); router.post('/admin/transactions/:paymentId', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateAddTransaction, paymentController.createTransaction);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete); router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete);
+212 -4
View File
@@ -1,6 +1,7 @@
// /components/payments/paymentService.js // /components/payments/paymentService.js
'use strict'; 'use strict';
const mongoose = require('mongoose');
const Payment = require('./paymentModel'); const Payment = require('./paymentModel');
const Transaction = require('./transactionModel'); const Transaction = require('./transactionModel');
const AppError = require('../../utils/AppError'); const AppError = require('../../utils/AppError');
@@ -28,7 +29,8 @@ const {
isPaidTransaction, isPaidTransaction,
isCancelledTransaction, isCancelledTransaction,
isActiveTransaction, isActiveTransaction,
sumPaidTransactions sumPaidTransactions,
formatPrice
} = require('../../utils/paymentAmount'); } = require('../../utils/paymentAmount');
const logger = require('../../utils/logger'); const logger = require('../../utils/logger');
@@ -36,7 +38,7 @@ const PAYMENT_METHODS = new Set(['online', 'card', 'cash']);
const PAYMENT_STATUS_LABELS = { const PAYMENT_STATUS_LABELS = {
pending: 'در انتظار پرداخت', pending: 'در انتظار پرداخت',
partial: 'پرداخت جزئی', partial: یش پرداخت',
paid: 'پرداخت‌شده', paid: 'پرداخت‌شده',
overdue: 'معوق' overdue: 'معوق'
}; };
@@ -112,7 +114,7 @@ const notifyTransactionRecorded = async (payment, transaction, source = {}) => {
phoneNumber: user.phoneNumber, phoneNumber: user.phoneNumber,
email: user.email, email: user.email,
subject: 'ثبت تراکنش', subject: 'ثبت تراکنش',
body: `تراکنش ${transaction.uniqueCode || ''} به مبلغ ${transaction.amount} تومان ثبت شد.`, body: `تراکنش ${transaction.uniqueCode || ''} به مبلغ ${formatPrice(transaction.amount)} تومان ثبت شد.`,
smsHandler: () => sendTransactionRecordedSms(user.phoneNumber, { smsHandler: () => sendTransactionRecordedSms(user.phoneNumber, {
fullName: user.name || '', fullName: user.name || '',
amount: transaction.amount, amount: transaction.amount,
@@ -257,6 +259,13 @@ const getAllPayments = async (query) => {
} }
if (query.userId) filter.user = query.userId; if (query.userId) filter.user = query.userId;
if (query.status) filter.status = query.status; if (query.status) filter.status = query.status;
if (query.classId) filter.classes = query.classId;
if (query.classes) {
const classList = Array.isArray(query.classes)
? query.classes
: query.classes.split(',').map((s) => s.trim()).filter(Boolean);
if (classList.length) filter.classes = { $in: classList };
}
const searchTerm = getSearchTerm(query); const searchTerm = getSearchTerm(query);
if (searchTerm) { if (searchTerm) {
@@ -302,6 +311,16 @@ const createPayment = async (body, actorId = null) => {
const incomingTransactions = Array.isArray(payload.transactions) ? payload.transactions : null; const incomingTransactions = Array.isArray(payload.transactions) ? payload.transactions : null;
delete payload.transactions; delete payload.transactions;
if (!payload.course && payload.classes && payload.classes.length) {
const firstClassId = Array.isArray(payload.classes) ? payload.classes[0] : payload.classes;
if (firstClassId) {
const firstClass = await Class.findById(firstClassId).select('course').lean();
if (firstClass?.course) {
payload.course = firstClass.course;
}
}
}
const payment = await Payment.create({ const payment = await Payment.create({
...payload, ...payload,
discount: normalizeDiscount(payload.discount, payload.amount), discount: normalizeDiscount(payload.discount, payload.amount),
@@ -333,7 +352,7 @@ const createPayment = async (body, actorId = null) => {
phoneNumber: user.phoneNumber, phoneNumber: user.phoneNumber,
email: user.email, email: user.email,
subject: 'ایجاد صورتحساب', subject: 'ایجاد صورتحساب',
body: `صورتحساب ${invoiceCode} به مبلغ ${getPayableAmount(payment)} تومان بابت «${courseName}» ایجاد شد.`, body: `صورتحساب ${invoiceCode} به مبلغ ${formatPrice(getPayableAmount(payment))} تومان بابت «${courseName}» ایجاد شد.`,
smsHandler: () => sendInvoiceCreatedSms(user.phoneNumber, { smsHandler: () => sendInvoiceCreatedSms(user.phoneNumber, {
fullName: user.name || '', fullName: user.name || '',
amount: getPayableAmount(payment), amount: getPayableAmount(payment),
@@ -481,6 +500,191 @@ const cancelTransaction = async (transactionId, actorId = null) => {
return getPaymentById(payment._id); return getPaymentById(payment._id);
}; };
const revertTransaction = async (transactionId, actorId = null) => {
const trx = await Transaction.findById(transactionId);
if (!trx) throw new AppError('TRANSACTION_NOT_FOUND');
if (trx.status === 'reverted') {
throw new AppError('VALIDATION_FAILED', {}, 'این تراکنش قبلاً مسترد شده است.');
}
const payment = await Payment.findById(trx.payment);
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
const previousStatus = payment.status;
trx.status = 'reverted';
if (actorId) trx.recordedBy = actorId;
await trx.save();
await refreshPaymentTotals(payment);
await emitPaymentStatusChangedIfNeeded(payment, previousStatus, actorId);
return getPaymentById(payment._id);
};
const deleteTransaction = async (transactionId) => {
const trx = await Transaction.findById(transactionId);
if (!trx) throw new AppError('TRANSACTION_NOT_FOUND');
const payment = await Payment.findById(trx.payment);
await Transaction.findByIdAndDelete(transactionId);
if (payment) {
const previousStatus = payment.status;
await refreshPaymentTotals(payment);
await emitPaymentStatusChangedIfNeeded(payment, previousStatus);
return getPaymentById(payment._id);
}
return null;
};
const createBulkClassPayments = async (body, actorId = null) => {
const { classId } = body;
if (!classId) {
throw new AppError('VALIDATION_FAILED', { classId: 'Class ID is required' }, 'شناسه کلاس الزامی است.');
}
const classDoc = await Class.findById(classId).populate('course', 'title price').lean();
if (!classDoc) {
throw new AppError('CLASS_NOT_FOUND', {}, 'کلاس مورد نظر یافت نشد.');
}
const allStudentIds = (classDoc.students || []).map((s) => String(s._id || s.id || s));
const targetStudentIds = (body.studentIds && Array.isArray(body.studentIds) && body.studentIds.length > 0)
? body.studentIds.map(String).filter((id) => allStudentIds.includes(id))
: allStudentIds;
if (!targetStudentIds.length) {
return {
message: 'هیچ دانشجویی در این کلاس ثبت‌نام نشده است.',
totalStudents: 0,
createdCount: 0,
skippedCount: 0,
payments: []
};
}
const skipExisting = body.skipExisting !== false;
let existingUserIds = new Set();
if (skipExisting) {
const existing = await Payment.find({
classes: classId,
user: { $in: targetStudentIds },
isDeleted: { $ne: true }
}).select('user').lean();
existingUserIds = new Set(existing.map((p) => String(p.user)));
}
const amount = body.amount !== undefined && body.amount !== null && body.amount !== ''
? Number(body.amount)
: (classDoc.tuitionFee || classDoc.course?.price || 0);
const discount = body.discount !== undefined && body.discount !== null && body.discount !== ''
? Number(body.discount)
: (classDoc.hasDiscount ? (classDoc.discount || 0) : 0);
const dueDate = parseDate(body.dueDate) || classDoc.startDate || new Date();
const notify = body.notify || {};
const notifySms = body.notifySms;
const notifyEmail = body.notifyEmail;
const notifyBot = body.notifyBot;
const createdPayments = [];
let skippedCount = 0;
for (const studentId of targetStudentIds) {
if (skipExisting && existingUserIds.has(studentId)) {
skippedCount++;
continue;
}
const payment = await createPayment({
user: studentId,
classes: [classId],
course: classDoc.course?._id || classDoc.course,
amount,
discount,
dueDate,
notes: body.notes,
notify,
notifySms,
notifyEmail,
notifyBot
}, actorId);
createdPayments.push(payment);
}
return {
totalStudents: targetStudentIds.length,
createdCount: createdPayments.length,
skippedCount,
payments: createdPayments
};
};
const checkDuplicatePayment = async (query = {}) => {
const { userId, classId, classes } = query;
if (!userId) {
return { hasDuplicate: false, count: 0, payments: [] };
}
const filter = {
isDeleted: { $ne: true }
};
const userConditions = [userId];
if (mongoose.isValidObjectId(userId)) {
userConditions.push(new mongoose.Types.ObjectId(String(userId)));
}
filter.user = { $in: userConditions };
const rawClasses = [];
if (classId) rawClasses.push(classId);
if (classes) {
if (Array.isArray(classes)) rawClasses.push(...classes);
else rawClasses.push(...String(classes).split(',').map((s) => s.trim()).filter(Boolean));
}
if (rawClasses.length) {
const classObjectIds = [];
rawClasses.forEach((id) => {
classObjectIds.push(id);
if (mongoose.isValidObjectId(id)) {
classObjectIds.push(new mongoose.Types.ObjectId(String(id)));
}
});
const classDocs = await Class.find({ _id: { $in: classObjectIds } }).select('course').lean();
const courseIds = classDocs.map((c) => c.course).filter(Boolean);
const courseObjectIds = [];
courseIds.forEach((id) => {
courseObjectIds.push(id);
if (mongoose.isValidObjectId(id)) {
courseObjectIds.push(new mongoose.Types.ObjectId(String(id)));
}
});
const orConditions = [{ classes: { $in: classObjectIds } }];
if (courseObjectIds.length) {
orConditions.push({ course: { $in: courseObjectIds } });
}
filter.$or = orConditions;
}
const payments = await Payment.find(filter)
.populate({ path: 'classes', select: 'name' })
.populate({ path: 'course', select: 'title' })
.select('_id uniqueCode amount discount paidAmount status classes course createdAt')
.lean();
return {
hasDuplicate: payments.length > 0,
count: payments.length,
payments
};
};
const getMyPayments = async (userId, query = {}) => { const getMyPayments = async (userId, query = {}) => {
return getAllPayments({ ...query, userId }); return getAllPayments({ ...query, userId });
}; };
@@ -489,12 +693,16 @@ module.exports = {
getAllPayments, getAllPayments,
getPaymentById, getPaymentById,
createPayment, createPayment,
createBulkClassPayments,
checkDuplicatePayment,
updatePayment, updatePayment,
deletePayment, deletePayment,
searchPayments, searchPayments,
addTransaction, addTransaction,
updateTransaction, updateTransaction,
cancelTransaction, cancelTransaction,
revertTransaction,
deleteTransaction,
getMyPayments, getMyPayments,
createTransactionsForPayment, createTransactionsForPayment,
refreshPaymentTotals, refreshPaymentTotals,
+1 -1
View File
@@ -4,7 +4,7 @@
const AppError = require('../../utils/AppError'); const AppError = require('../../utils/AppError');
const PAYMENT_METHODS = new Set(['online', 'card', 'cash']); const PAYMENT_METHODS = new Set(['online', 'card', 'cash']);
const TRANSACTION_STATUSES = new Set(['pending', 'paid']); const TRANSACTION_STATUSES = new Set(['pending', 'paid', 'cancelled', 'reverted']);
const passThrough = (req, res, next) => next(); const passThrough = (req, res, next) => next();
+1 -1
View File
@@ -36,7 +36,7 @@ const transactionSchema = new mongoose.Schema({
}, },
status: { status: {
type: String, type: String,
enum: ['pending', 'paid', 'cancelled'], enum: ['pending', 'paid', 'cancelled', 'reverted'],
default: 'pending', default: 'pending',
index: true index: true
} }
@@ -1,4 +1,5 @@
// /components/professors/professorController.js // /components/professors/professorController.js
'use strict';
const catchAsync = require('../../utils/catchAsync'); const catchAsync = require('../../utils/catchAsync');
const professorService = require('./professorService'); const professorService = require('./professorService');
@@ -9,6 +10,12 @@ exports.create = catchAsync(async (req, res, next) => {
return successResponse(res, 201, 'Professor created successfully', professor); return successResponse(res, 201, 'Professor created successfully', professor);
}); });
exports.createFromUser = catchAsync(async (req, res, next) => {
const userId = req.body.userId || req.params.userId || req.body.id;
const result = await professorService.createProfessorFromUser(userId, req.body);
return successResponse(res, 201, 'Professor created from user successfully', result);
});
exports.getOne = catchAsync(async (req, res, next) => { exports.getOne = catchAsync(async (req, res, next) => {
const professor = await professorService.getProfessorById(req.params.id); const professor = await professorService.getProfessorById(req.params.id);
return successResponse(res, 200, 'Professor retrieved successfully', professor); return successResponse(res, 200, 'Professor retrieved successfully', professor);
@@ -33,3 +40,9 @@ exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await professorService.searchProfessors(req.query); const { data, meta } = await professorService.searchProfessors(req.query);
return listResponse(res, 200, data, meta); 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);
});
+60 -34
View File
@@ -1,45 +1,16 @@
// /components/professors/professorModel.js // /components/professors/professorModel.js
'use strict';
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const professorSchema = new mongoose.Schema({ const professorSchema = new mongoose.Schema({
nationalIdCode: { user: {
type: String, type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true, required: true,
unique: true, unique: true,
trim: true,
index: 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: { bio: {
type: String, type: String,
trim: true trim: true
@@ -57,7 +28,62 @@ const professorSchema = new mongoose.Schema({
default: true 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); module.exports = mongoose.model('Professor', professorSchema);
+8
View File
@@ -1,4 +1,5 @@
// /components/professors/professorRoutes.js // /components/professors/professorRoutes.js
'use strict';
const express = require('express'); const express = require('express');
const professorController = require('./professorController'); const professorController = require('./professorController');
@@ -11,7 +12,14 @@ const router = express.Router();
router.use(authMiddleware); 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', 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); router.get('/admin/get-all', perm.requires(PERMISSIONS.PROFESSORS_READ), professorController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.PROFESSORS_SEARCH), professorController.search); router.get('/admin/search', perm.requires(PERMISSIONS.PROFESSORS_SEARCH), professorController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PROFESSORS_READ), professorController.getOne); router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PROFESSORS_READ), professorController.getOne);
+586 -58
View File
@@ -1,104 +1,370 @@
// /components/professors/professorService.js // /components/professors/professorService.js
'use strict';
const mongoose = require('mongoose');
const Professor = require('./professorModel'); 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 AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter'); const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames'); 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 { 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) => { 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(); const phoneNumber = String(data.phoneNumber || data.phone || '').trim();
let nationalIdCode = String(data.nationalIdCode || data.nationalId || '').trim();
if (!nationalIdCode) { if (!user && phoneNumber) {
nationalIdCode = await allocatePlaceholderNationalId(phoneNumber); user = await User.findOne({ phoneNumber });
}
if (!user && nationalIdCode) {
user = await User.findOne({ nationalIdCode });
} }
const payload = { if (!user) {
...data, if (!nationalIdCode) {
name: String(data.name || '').trim(), nationalIdCode = await allocatePlaceholderNationalId(phoneNumber || data.name);
surname: String(data.surname || '').trim(), }
nationalIdCode, const fullName = String(data.name || '').trim() + (data.surname ? ` ${String(data.surname).trim()}` : '');
phoneNumber, const username = generateUsername();
email: data.email ? String(data.email).trim().toLowerCase() : undefined, const plainPassword = generateSimplePassword();
cardNumber: data.cardNumber ? String(data.cardNumber).trim() : undefined, const passwordHash = await bcrypt.hash(plainPassword, 10);
shabaNumber: data.shabaNumber || data.iban ? String(data.shabaNumber || data.iban).trim() : undefined,
bio: data.bio ? String(data.bio).trim() : undefined
};
const existing = await Professor.findOne({ user = await User.create({
$or: [ name: fullName.trim() || `استاد ${phoneNumber}`,
{ nationalIdCode: payload.nationalIdCode }, nationalIdCode,
{ phoneNumber: payload.phoneNumber }, phoneNumber: phoneNumber || `0999${Date.now().toString().slice(-7)}`,
...(payload.email ? [{ email: payload.email }] : []) 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 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, 'شناسه کاربر الزامی است');
} }
const professor = await Professor.create(payload); const user = await User.findById(userId);
eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, { professorId: professor._id, name: `${professor.name} ${professor.surname}` }); if (!user) {
return professor; throw new AppError('USER_NOT_FOUND');
}
const professorRole = await ensureProfessorRole();
if (String(user.role) !== String(professorRole._id)) {
user.role = professorRole._id;
}
let firstName = String(additionalData.name || '').trim();
let lastName = String(additionalData.surname || '').trim();
if (firstName || lastName) {
user.name = `${firstName} ${lastName}`.trim();
}
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();
let expertise = [];
if (Array.isArray(additionalData.expertise)) {
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);
}
let professor = await Professor.findOne({ user: user._id });
if (professor) {
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({
user: user._id,
bio: additionalData.bio ? String(additionalData.bio).trim() : undefined,
expertise,
isActive: true
});
}
eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, {
professorId: professor._id,
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: formatProfessorDoc(populated),
user: updatedUser
};
}; };
const getProfessorById = async (id) => { 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) { if (!professor) {
throw new AppError('PROFESSOR_NOT_FOUND'); throw new AppError('PROFESSOR_NOT_FOUND');
} }
return professor;
return formatProfessorDoc(professor);
}; };
const getAllProfessors = async (queryParams) => { const getAllProfessors = async (queryParams = {}) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams); const page = parseInt(queryParams.page, 10) || 1;
const filter = buildFilterQuery(queryParams, ['name', 'surname', 'nationalIdCode', 'phoneNumber', 'email', 'expertise']); 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([ 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) 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); const meta = calculateMeta(totalCount, page, limit);
return { data: professors, meta }; return { data: formatted, meta };
}; };
const updateProfessor = async (id, updateData) => { const updateProfessor = async (id, updateData = {}) => {
const professor = await Professor.findById(id); 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) { if (!professor) {
throw new AppError('PROFESSOR_NOT_FOUND'); throw new AppError('PROFESSOR_NOT_FOUND');
} }
const payload = { ...updateData }; if (updateData.bio !== undefined) {
if (payload.nationalIdCode !== undefined || payload.nationalId !== undefined) { professor.bio = updateData.bio ? String(updateData.bio).trim() : undefined;
payload.nationalIdCode = String(payload.nationalIdCode || payload.nationalId || '').trim();
delete payload.nationalId;
} }
if (payload.phoneNumber !== undefined || payload.phone !== undefined) { if (updateData.expertise !== undefined) {
payload.phoneNumber = String(payload.phoneNumber || payload.phone || '').trim(); if (Array.isArray(updateData.expertise)) {
delete payload.phone; 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 (updateData.courses !== undefined) {
if (payload.surname !== undefined) payload.surname = String(payload.surname).trim(); professor.courses = updateData.courses;
if (payload.email !== undefined) {
payload.email = payload.email ? String(payload.email).trim().toLowerCase() : undefined;
} }
if (payload.cardNumber !== undefined) { if (updateData.isActive !== undefined) {
payload.cardNumber = payload.cardNumber ? String(payload.cardNumber).trim() : undefined; professor.isActive = Boolean(updateData.isActive);
} }
if (payload.shabaNumber !== undefined || payload.iban !== undefined) {
const val = payload.shabaNumber || payload.iban; // Update User personal details
payload.shabaNumber = val ? String(val).trim() : undefined; if (professor.user) {
delete payload.iban; const user = await User.findById(professor.user._id || professor.user);
} if (user) {
if (payload.bio !== undefined) { if (updateData.name || updateData.surname) {
payload.bio = payload.bio ? String(payload.bio).trim() : undefined; 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(); 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) => { const deleteProfessor = async (id) => {
@@ -115,11 +381,273 @@ const searchProfessors = async (queryParams) => {
return getAllProfessors(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 = { module.exports = {
createProfessor, createProfessor,
createProfessorFromUser,
getProfessorById, getProfessorById,
getAllProfessors, getAllProfessors,
updateProfessor, updateProfessor,
deleteProfessor, 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);
});
});
+6
View File
@@ -57,6 +57,12 @@ exports.updateAttendance = catchAsync(async (req, res, next) => {
return successResponse(res, 200, 'Session attendance updated successfully', session); return successResponse(res, 200, 'Session attendance updated successfully', session);
}); });
exports.notifyHolding = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const result = await sessionService.notifySessionHolding(req.params.id, actorId);
return successResponse(res, 200, 'اطلاع‌رسانی برگزاری جلسه با موفقیت انجام شد', result);
});
exports.getMySessions = catchAsync(async (req, res, next) => { exports.getMySessions = catchAsync(async (req, res, next) => {
const { data, meta } = await sessionService.getMySessions(req.user._id, req.query); const { data, meta } = await sessionService.getMySessions(req.user._id, req.query);
return listResponse(res, 200, data, meta); return listResponse(res, 200, data, meta);
+1
View File
@@ -28,5 +28,6 @@ router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.SESSIONS_DELETE), s
router.post('/admin/bulk-delete', perm.requires(PERMISSIONS.SESSIONS_DELETE), sessionController.bulkDelete); router.post('/admin/bulk-delete', perm.requires(PERMISSIONS.SESSIONS_DELETE), sessionController.bulkDelete);
router.post('/admin/bulk-status', perm.requires(PERMISSIONS.SESSIONS_UPDATE), sessionController.bulkUpdateStatus); router.post('/admin/bulk-status', perm.requires(PERMISSIONS.SESSIONS_UPDATE), sessionController.bulkUpdateStatus);
router.put('/admin/:id/attendance', perm.requires(PERMISSIONS.SESSIONS_ATTENDANCE), validateUpdateAttendanceList, sessionController.updateAttendance); router.put('/admin/:id/attendance', perm.requires(PERMISSIONS.SESSIONS_ATTENDANCE), validateUpdateAttendanceList, sessionController.updateAttendance);
router.post('/admin/:id/notify-holding', perm.requires(PERMISSIONS.SESSIONS_READ), sessionController.notifyHolding);
module.exports = router; module.exports = router;
+88 -6
View File
@@ -9,6 +9,9 @@ const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter'); const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames'); const EVENT_NAMES = require('../../constants/eventNames');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination'); const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const { notifyAction } = require('../../utils/actionNotify');
const { sendSessionHoldingSms } = require('../../utils/senders/smsMessages');
const logger = require('../../utils/logger');
const STATUS_MAP = { const STATUS_MAP = {
scheduled: 'scheduled', scheduled: 'scheduled',
@@ -234,7 +237,7 @@ const populateSessionList = (query, { includeClassStudents = false } = {}) => {
}; };
const getAllSessions = async (queryParams) => { const getAllSessions = async (queryParams) => {
const { page, limit, skip } = parsePaginationAndSort(queryParams, 'day', 'asc'); const { page, limit, skip, sort } = parsePaginationAndSort(queryParams, 'day', 'asc');
const attendanceScope = queryParams.attendanceScope; const attendanceScope = queryParams.attendanceScope;
const filter = buildFilterQuery(queryParams, ['topic', 'place', 'note'], [ const filter = buildFilterQuery(queryParams, ['topic', 'place', 'note'], [
'page', 'page',
@@ -269,10 +272,9 @@ const getAllSessions = async (queryParams) => {
const includeClassStudents = Boolean(attendanceScope); const includeClassStudents = Boolean(attendanceScope);
const matched = await populateSessionList(Session.find(filter), { includeClassStudents }).lean();
let sessions = sortByClosestAttendance(matched);
if (attendanceScope) { if (attendanceScope) {
const matched = await populateSessionList(Session.find(filter), { includeClassStudents }).lean();
let sessions = sortByClosestAttendance(matched);
const studentsByClassId = await buildStudentsByClassId(matched); const studentsByClassId = await buildStudentsByClassId(matched);
sessions = filterByAttendanceScope(sessions, attendanceScope, studentsByClassId) sessions = filterByAttendanceScope(sessions, attendanceScope, studentsByClassId)
.map((session) => enrichSessionAttendance(session, studentsByClassId)); .map((session) => enrichSessionAttendance(session, studentsByClassId));
@@ -282,8 +284,16 @@ const getAllSessions = async (queryParams) => {
return { data: sessions, meta }; return { data: sessions, meta };
} }
const totalCount = await Session.countDocuments(filter); const effectiveSort = { ...sort };
sessions = sessions.slice(skip, skip + limit); if (effectiveSort.day) {
effectiveSort.startTime = effectiveSort.day;
}
const [sessions, totalCount] = await Promise.all([
populateSessionList(Session.find(filter).sort(effectiveSort).skip(skip).limit(limit), { includeClassStudents: false }).lean(),
Session.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit); const meta = calculateMeta(totalCount, page, limit);
return { data: sessions, meta }; return { data: sessions, meta };
}; };
@@ -430,6 +440,77 @@ const getMySessions = async (userId, queryParams) => {
return { data: sessions, meta }; return { data: sessions, meta };
}; };
const notifySessionHolding = async (sessionId, actorId = null) => {
const session = await Session.findById(sessionId)
.populate({
path: 'class',
select: 'name uniqueCode students startTime place'
})
.populate('course', 'title');
if (!session) {
throw new AppError('SESSION_NOT_FOUND');
}
const classDoc = session.class;
let studentIds = classDoc?.students?.length
? classDoc.students
: (await User.find({ courses: session.course?._id || session.course }).select('_id')).map((u) => u._id);
if (!studentIds.length) {
throw new AppError('NOT_FOUND', null, 'هیچ دانشجویی در این کلاس ثبت‌نام نشده است.');
}
const sessionDate = session.day
? new Date(session.day).toLocaleDateString('fa-IR')
: '';
const className = classDoc?.name || session.course?.title || 'کلاس';
const classCode = classDoc?.uniqueCode || '';
const timeLabel = session.startTime || '';
const topicLabel = session.topic || className;
const users = await User.find({ _id: { $in: studentIds } }).select('name phoneNumber email').lean();
const validUsers = users.filter((u) => u.phoneNumber);
if (!validUsers.length) {
throw new AppError('NOT_FOUND', null, 'هیچ دانشجویی با شماره همراه معتبر در این کلاس یافت نشد.');
}
let sentCount = 0;
for (const student of validUsers) {
try {
await notifyAction({
actionKey: 'sessionHolding',
userId: student._id,
phoneNumber: student.phoneNumber,
email: student.email,
subject: 'برگزاری جلسه طبق برنامه',
body: `جلسه «${topicLabel}» کلاس ${className} در تاریخ ${sessionDate} و ساعت ${timeLabel} طبق برنامه برگزار خواهد شد.`,
smsHandler: () => sendSessionHoldingSms(student.phoneNumber, {
fullName: student.name || '',
className,
topic: topicLabel,
sessionDate,
classTime: timeLabel,
courseName: session.course?.title || className,
classCode,
place: session.place || '-'
}, student._id),
requestSource: { notifySms: true, notifyEmail: true, notifyBot: true }
});
sentCount += 1;
} catch (err) {
logger.error(`[notifySessionHolding] Failed for user ${student._id}: ${err.message}`);
}
}
return {
success: true,
sentCount,
totalStudents: validUsers.length
};
};
module.exports = { module.exports = {
createSession, createSession,
getSessionById, getSessionById,
@@ -441,6 +522,7 @@ module.exports = {
searchSessions, searchSessions,
updateSessionAttendance, updateSessionAttendance,
getMySessions, getMySessions,
notifySessionHolding,
isSessionDue, isSessionDue,
hasCompleteAttendance, hasCompleteAttendance,
isAttendancePending isAttendancePending
+53 -5
View File
@@ -13,17 +13,32 @@ let cache = null;
let cacheAt = 0; let cacheAt = 0;
const CACHE_TTL_MS = 10000; 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) => ({ const readDbFlags = (doc) => ({
smsEnabled: doc?.smsEnabled, smsEnabled: doc?.smsEnabled,
emailEnabled: doc?.emailEnabled, 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 getDbMessagingFlags = async () => {
const now = Date.now(); const now = Date.now();
if (cache && (now - cacheAt) < CACHE_TTL_MS) return cache; if (cache && (now - cacheAt) < CACHE_TTL_MS) return cache;
const doc = await Setting.findOne({ key: SETTINGS_KEY }) const doc = await Setting.findOne({ key: SETTINGS_KEY })
.select('smsEnabled emailEnabled botEnabled') .select('smsEnabled emailEnabled botEnabled smsBypassNumbers')
.lean(); .lean();
cache = readDbFlags(doc); cache = readDbFlags(doc);
cacheAt = now; cacheAt = now;
@@ -35,13 +50,42 @@ const invalidateMessagingCache = () => {
cacheAt = 0; 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 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) { if (!envEnabled) {
return { return {
enabled: false, enabled: false,
envEnabled: false, envEnabled: false,
dbEnabled: null, dbEnabled: null,
bypass: false,
blockReason: 'env_disabled' blockReason: 'env_disabled'
}; };
} }
@@ -53,6 +97,7 @@ const getMessagingChannelState = async (channel) => {
enabled: dbEnabled, enabled: dbEnabled,
envEnabled: true, envEnabled: true,
dbEnabled, dbEnabled,
bypass: false,
blockReason: dbEnabled ? null : 'dashboard_disabled' blockReason: dbEnabled ? null : 'dashboard_disabled'
}; };
} catch (err) { } catch (err) {
@@ -61,13 +106,14 @@ const getMessagingChannelState = async (channel) => {
enabled: true, enabled: true,
envEnabled: true, envEnabled: true,
dbEnabled: null, dbEnabled: null,
bypass: false,
blockReason: null blockReason: null
}; };
} }
}; };
const isMessagingChannelEnabled = async (channel) => { const isMessagingChannelEnabled = async (channel, recipientPhone = null) => {
const state = await getMessagingChannelState(channel); const state = await getMessagingChannelState(channel, recipientPhone);
return state.enabled; return state.enabled;
}; };
@@ -76,6 +122,8 @@ const getPublicMessaging = (doc) => toPublicMessaging(readDbFlags(doc), config);
module.exports = { module.exports = {
getMessagingChannelState, getMessagingChannelState,
isMessagingChannelEnabled, isMessagingChannelEnabled,
isSmsBypassNumber,
normalizePhoneForBypass,
invalidateMessagingCache, invalidateMessagingCache,
getPublicMessaging, getPublicMessaging,
getDbMessagingFlags getDbMessagingFlags
@@ -65,6 +65,15 @@ const NOTIFICATION_ACTION_DEFS = [
smsTemplateKey: 'sessionHolding', smsTemplateKey: 'sessionHolding',
defaultChannels: { sms: true, email: false, bot: false } defaultChannels: { sms: true, email: false, bot: false }
}, },
{
key: 'classPlanProfessor',
label: 'ارسال برنامه کلاس به استاد',
description: 'ارسال جزئیات و زمان‌بندی برگزاری کلاس به استاد',
category: 'classes',
relatedEvent: 'class.plan_to_professor',
smsTemplateKey: 'classPlanProfessor',
defaultChannels: { sms: true, email: false, bot: false }
},
{ {
key: 'invoiceCreated', key: 'invoiceCreated',
label: 'ایجاد صورتحساب', label: 'ایجاد صورتحساب',
+27
View File
@@ -4,6 +4,29 @@ const mongoose = require('mongoose');
const SETTINGS_KEY = 'app'; 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({ const settingSchema = new mongoose.Schema({
key: { key: {
type: String, type: String,
@@ -28,6 +51,10 @@ const settingSchema = new mongoose.Schema({
type: Boolean, type: Boolean,
default: true default: true
}, },
smsBypassNumbers: {
type: [bypassNumberSchema],
default: []
},
notificationSettings: { notificationSettings: {
type: mongoose.Schema.Types.Mixed, type: mongoose.Schema.Types.Mixed,
default: {} default: {}
+50 -6
View File
@@ -13,7 +13,8 @@ const {
const { parseIncomingMessaging } = require('../../utils/messagingChannels'); const { parseIncomingMessaging } = require('../../utils/messagingChannels');
const { const {
getPublicMessaging, getPublicMessaging,
invalidateMessagingCache invalidateMessagingCache,
normalizePhoneForBypass
} = require('./messagingFlags'); } = require('./messagingFlags');
const { const {
emptyNotificationSettingsMap, emptyNotificationSettingsMap,
@@ -61,6 +62,8 @@ const toPublicTemplates = (storedMap, notificationMap = {}) => {
return { return {
key: def.key, key: def.key,
label: def.label, label: def.label,
category: entry.category || def.category || 'اطلاع‌رسانی',
text: entry.text || def.defaultText || '',
enabled: isEnabled, enabled: isEnabled,
templateId: resolveTemplateId(entry, def), templateId: resolveTemplateId(entry, def),
availableSlots: (def.slots || []).map((slot) => ({ availableSlots: (def.slots || []).map((slot) => ({
@@ -80,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 () => { const getSettings = async () => {
let doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); let doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
if (!doc) { if (!doc) {
const created = await Setting.create({ const created = await Setting.create({
key: SETTINGS_KEY, key: SETTINGS_KEY,
smsTemplates: emptyTemplateMap(), smsTemplates: emptyTemplateMap(),
notificationSettings: emptyNotificationSettingsMap() notificationSettings: emptyNotificationSettingsMap(),
smsBypassNumbers: []
}); });
doc = created.toObject(); doc = created.toObject();
} else { } else {
@@ -108,14 +126,15 @@ const getSettings = async () => {
return { return {
smsTemplates: toPublicTemplates(storedMap, notificationMap), smsTemplates: toPublicTemplates(storedMap, notificationMap),
messaging: getPublicMessaging(doc), messaging: getPublicMessaging(doc),
notificationSettings: await getPublicNotificationSettings(doc) notificationSettings: await getPublicNotificationSettings(doc),
smsBypassNumbers: formatBypassNumbers(doc.smsBypassNumbers)
}; };
}; };
const getSmsTemplate = async (key) => { const getSmsTemplate = async (key) => {
const def = SMS_TEMPLATE_DEFS.find((item) => item.key === key); const def = SMS_TEMPLATE_DEFS.find((item) => item.key === key);
if (!def) { if (!def) {
return { templateId: '', variables: [], enabled: true }; return { templateId: '', variables: [], enabled: true, text: '', category: 'اطلاع‌رسانی' };
} }
const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean(); const doc = await Setting.findOne({ key: SETTINGS_KEY }).lean();
const entry = normalizeStoredEntry(readStoredMap(doc)[key], def); const entry = normalizeStoredEntry(readStoredMap(doc)[key], def);
@@ -128,6 +147,8 @@ const getSmsTemplate = async (key) => {
return { return {
enabled: isEnabled, enabled: isEnabled,
templateId: resolveTemplateId(entry, def), templateId: resolveTemplateId(entry, def),
category: entry.category || def.category || 'اطلاع‌رسانی',
text: entry.text || def.defaultText || '',
variables: resolveVariablesList(def, entry.variables) variables: resolveVariablesList(def, entry.variables)
}; };
}; };
@@ -140,12 +161,14 @@ const getSmsTemplateId = async (key) => {
const parseIncomingEntry = (raw) => { const parseIncomingEntry = (raw) => {
if (raw == null) return null; if (raw == null) return null;
if (typeof raw === 'string' || typeof raw === 'number') { if (typeof raw === 'string' || typeof raw === 'number') {
return { templateId: raw, variables: undefined, enabled: undefined }; return { templateId: raw, variables: undefined, enabled: undefined, text: undefined, category: undefined };
} }
if (typeof raw !== 'object') return null; if (typeof raw !== 'object') return null;
return { return {
enabled: raw.enabled !== undefined ? Boolean(raw.enabled) : undefined, enabled: raw.enabled !== undefined ? Boolean(raw.enabled) : undefined,
templateId: raw.templateId, templateId: raw.templateId,
category: raw.category,
text: raw.text,
variables: raw.variables variables: raw.variables
}; };
}; };
@@ -244,6 +267,26 @@ const saveSettings = async (body = {}) => {
doc.set('notificationSettings', emptyNotificationSettingsMap()); 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(); await doc.save();
invalidateMessagingCache(); invalidateMessagingCache();
invalidateNotificationSettingsCache(); invalidateNotificationSettingsCache();
@@ -253,7 +296,8 @@ const saveSettings = async (body = {}) => {
return { return {
smsTemplates: toPublicTemplates(readStoredMap(saved), finalNotificationMap), smsTemplates: toPublicTemplates(readStoredMap(saved), finalNotificationMap),
messaging: getPublicMessaging(saved), messaging: getPublicMessaging(saved),
notificationSettings: await getPublicNotificationSettings(saved) notificationSettings: await getPublicNotificationSettings(saved),
smsBypassNumbers: formatBypassNumbers(saved.smsBypassNumbers)
}; };
}; };
+37
View File
@@ -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
});
});
@@ -0,0 +1,63 @@
// /components/settings/smsTemplateRender.test.js
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { renderSmsText, SMS_TEMPLATE_DEFS } = require('./smsTemplates');
const { formatJalaliDate } = require('../../utils/jalaliDate');
describe('SMS Template Text Rendering', () => {
it('renders class plan to professor template correctly with variables', () => {
const def = SMS_TEMPLATE_DEFS.find((d) => d.key === 'classPlanProfessor');
assert.ok(def);
const slotValues = {
professorName: 'علی رضایی',
className: 'برنامه‌نویسی وب',
classDays: 'شنبه و دوشنبه',
classTimes: '۱۶:۰۰ الی ۱۸:۰۰',
classStartDate: '1405/06/01',
classEndDate: '1405/08/15'
};
const rendered = renderSmsText(def.defaultText, def.slots.map(s => ({ slot: s.key, name: s.defaultName })), slotValues);
assert.match(rendered, /استاد علی رضایی/);
assert.match(rendered, /برنامه‌نویسی وب/);
assert.match(rendered, /شنبه و دوشنبه/);
assert.match(rendered, /۱۶:۰۰ الی ۱۸:۰۰/);
assert.match(rendered, /1405\/06\/01/);
assert.match(rendered, /1405\/08\/15/);
});
it('renders sessionHolding template correctly', () => {
const def = SMS_TEMPLATE_DEFS.find((d) => d.key === 'sessionHolding');
assert.ok(def);
const slotValues = {
fullName: 'سارا محمدی',
topic: 'مقدمه‌ای بر پایگاه داده',
className: 'کلاس بک‌اند',
sessionDate: '1405/06/10',
classTime: '17:00'
};
const rendered = renderSmsText(def.defaultText, [
{ slot: 'fullName', name: 'FULLNAME' },
{ slot: 'topic', name: 'TOPIC' },
{ slot: 'className', name: 'CLASSNAME' },
{ slot: 'sessionDate', name: 'SESSIONDATE' },
{ slot: 'classTime', name: 'CLASSTIME' }
], slotValues);
assert.match(rendered, /سارا محمدی عزیز/);
assert.match(rendered, /مقدمه‌ای بر پایگاه داده/);
assert.match(rendered, /کلاس بک‌اند/);
assert.match(rendered, /1405\/06\/10/);
assert.match(rendered, /17:00/);
});
it('formats dates into Jalali correctly', () => {
const formatted = formatJalaliDate('2026-08-24T00:00:00.000Z');
assert.equal(formatted, '1405/06/02');
});
});
+368 -159
View File
@@ -1,137 +1,17 @@
// /components/settings/smsTemplates.js
'use strict'; 'use strict';
const SMS_TEMPLATE_DEFS = [ const SMS_TEMPLATE_DEFS = [
{
key: 'accountCreated',
label: 'ایجاد حساب کاربری',
envKey: 'SMS_TEMPLATE_ACCOUNT_CREATED',
slots: [
{ key: 'username', label: 'نام کاربری', defaultName: 'user' },
{ key: 'password', label: 'رمز عبور', defaultName: 'password' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'name' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
]
},
{
key: 'classRegistered',
label: 'ثبت‌نام در کلاس',
envKey: 'SMS_TEMPLATE_CLASS_REGISTERED',
slots: [
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
]
},
{
key: 'classReminder',
label: 'یادآوری کلاس',
envKey: 'SMS_TEMPLATE_CLASS_REMINDER',
slots: [
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
{ key: 'time', label: 'ساعت', defaultName: 'time' },
{ key: 'classTime', label: 'ساعت کلاس', defaultName: 'classTime' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' },
{ key: 'place', label: 'مکان', defaultName: 'place' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'fullName' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
]
},
{
key: 'invoiceCreated',
label: 'ایجاد صورتحساب',
envKey: 'SMS_TEMPLATE_INVOICE_CREATED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'amount', label: 'مبلغ', defaultName: 'PAYMENT_PRICE' },
{ key: 'course', label: 'نام دوره', defaultName: 'COURSE' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }
]
},
{
key: 'passwordReset',
label: 'بازنشانی رمز عبور',
envKey: 'SMS_TEMPLATE_PASSWORD_RESET',
slots: [
{ key: 'username', label: 'نام کاربری', defaultName: 'user' },
{ key: 'password', label: 'رمز عبور', defaultName: 'password' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'name' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
]
},
{
key: 'paymentStatusChanged',
label: 'تغییر وضعیت پرداخت',
envKey: 'SMS_TEMPLATE_PAYMENT_STATUS_CHANGED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'status', label: 'وضعیت', defaultName: 'status' },
{ key: 'amount', label: 'مبلغ', defaultName: 'amount' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
{ key: 'course', label: 'نام دوره', defaultName: 'course' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'paymentReminder',
label: 'یادآوری سررسید پرداخت',
envKey: 'SMS_TEMPLATE_PAYMENT_REMINDER',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'amount', label: 'مبلغ باقی‌مانده', defaultName: 'amount' },
{ key: 'dueDate', label: 'تاریخ سررسید', defaultName: 'dueDate' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
{ key: 'course', label: 'نام دوره', defaultName: 'course' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'transactionRecorded',
label: 'ثبت تراکنش',
envKey: 'SMS_TEMPLATE_TRANSACTION_RECORDED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'amount', label: 'مبلغ', defaultName: 'amount' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' },
{ key: 'transactionCode', label: 'کد تراکنش', defaultName: 'transactionCode' },
{ key: 'receiptNumber', label: 'شماره رسید', defaultName: 'receiptNumber' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'sessionCancelled',
label: 'لغو جلسه',
envKey: 'SMS_TEMPLATE_SESSION_CANCELLED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'sessionDate' },
{ key: 'reason', label: 'دلیل لغو', defaultName: 'reason' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{ {
key: 'sessionHolding', key: 'sessionHolding',
label: 'برگزاری جلسه طبق برنامه', label: 'کلاس طبق برنامه',
category: 'اطلاع‌رسانی',
defaultTemplateId: '720661',
envKey: 'SMS_TEMPLATE_SESSION_HOLDING', envKey: 'SMS_TEMPLATE_SESSION_HOLDING',
defaultText: ` #FULLNAME# عزیز، «#TOPIC#» کلاس #CLASSNAME# در تاریخ #SESSIONDATE# و ساعت #CLASSTIME# طبق برنامه برگزار خواهد شد.
آموزشگاه گام نو
game-no.ir`,
slots: [ slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' }, { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' }, { key: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' },
@@ -144,14 +24,245 @@ const SMS_TEMPLATE_DEFS = [
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
] ]
}, },
{
key: 'certificateIssued',
label: 'صدور گواهی',
category: 'اطلاع‌رسانی',
defaultTemplateId: '854493',
envKey: 'SMS_TEMPLATE_CERTIFICATE_ISSUED',
defaultText: ` #FULLNAME# عزیز، گواهی «#CERTIFICATETITLE#» شما، برای دوره #COURSENAME# صادر شد.
جهت مشاهده و دانلود گواهی به پروفایل خود در وبسایت آموزشگاه مراجعه کنید.
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'certificateTitle', label: 'عنوان گواهینامه', defaultName: 'CERTIFICATETITLE' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' },
{ key: 'certificateCode', label: 'کد گواهینامه', defaultName: 'certificateCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'sessionCancelled',
label: 'لغو کلاس',
category: 'اطلاع‌رسانی',
defaultTemplateId: '174248',
envKey: 'SMS_TEMPLATE_SESSION_CANCELLED',
defaultText: ` #FULLNAME# عزیز، جلسه «#TOPIC#» کلاس #CLASSNAME# در تاریخ #SESSIONDATE# برگزار نخواهد شد.
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' },
{ key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' },
{ key: 'reason', label: 'دلیل لغو', defaultName: 'reason' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'transactionRecorded',
label: 'ثبت تراکنش',
category: 'اطلاع‌رسانی',
defaultTemplateId: '580983',
envKey: 'SMS_TEMPLATE_TRANSACTION_RECORDED',
defaultText: ` #FULLNAME# عزیز، تراکنش #TRANSACTIONCODE# به مبلغ #AMOUNT# تومان ثبت شد.
صورتحساب: #INVOICECODE# — رسید: #RECEIPTNUMBER#
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'transactionCode', label: 'کد تراکنش', defaultName: 'TRANSACTIONCODE' },
{ key: 'amount', label: 'مبلغ', defaultName: 'AMOUNT' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' },
{ key: 'receiptNumber', label: 'شماره رسید', defaultName: 'RECEIPTNUMBER' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'paymentReminder',
label: 'یادآوری پرداخت',
category: 'اطلاع‌رسانی',
defaultTemplateId: '357550',
envKey: 'SMS_TEMPLATE_PAYMENT_REMINDER',
defaultText: ` #FULLNAME# عزیز، یادآوری پرداخت:
صورتحساب #INVOICECODE# — مبلغ باقیمانده: #AMOUNT# تومان
سررسید: #DUEDATE#
دوره: #COURSE#
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' },
{ key: 'amount', label: 'مبلغ باقیمانده', defaultName: 'AMOUNT' },
{ key: 'dueDate', label: 'تاریخ سررسید', defaultName: 'DUEDATE' },
{ key: 'course', label: 'نام دوره', defaultName: 'COURSE' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'paymentStatusChanged',
label: 'وضعیت صورتحساب',
category: 'وضعیت سفارش',
defaultTemplateId: '270174',
envKey: 'SMS_TEMPLATE_PAYMENT_STATUS_CHANGED',
defaultText: ` #FULLNAME# عزیز، وضعیت صورتحساب #INVOICECODE# به «#STATUS#» تغییر کرد.
مبلغ: #AMOUNT# تومان — دوره: #COURSE#
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' },
{ key: 'status', label: 'وضعیت', defaultName: 'STATUS' },
{ key: 'amount', label: 'مبلغ', defaultName: 'AMOUNT' },
{ key: 'course', label: 'نام دوره', defaultName: 'COURSE' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'accountCreated',
label: 'اطلاعات اکانت',
category: 'اطلاع‌رسانی',
defaultTemplateId: '250973',
envKey: 'SMS_TEMPLATE_ACCOUNT_CREATED',
defaultText: ` کارآموز گرامی، یک حساب کاربری برای شما در وبسایت آموزشگاه گام نو ایجاد شد.
نام کاربری: #USER#
رمز عبور: #PASSWORD#
game-no.ir`,
slots: [
{ key: 'username', label: 'نام کاربری', defaultName: 'USER' },
{ key: 'password', label: 'رمز عبور', defaultName: 'PASSWORD' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'name' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
]
},
{
key: 'invoiceCreated',
label: 'پرداخت',
category: 'اطلاع‌رسانی',
defaultTemplateId: '580892',
envKey: 'SMS_TEMPLATE_INVOICE_CREATED',
defaultText: ` کارآموز عزیز، #FULLNAME#، یک صورتحساب به مبلغ #PAYMENT_PRICE# تومان، بابت دوره ی #COURSE# برای شما ایجاد شده است.
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'amount', label: 'مبلغ', defaultName: 'PAYMENT_PRICE' },
{ key: 'course', label: 'نام دوره', defaultName: 'COURSE' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }
]
},
{
key: 'classRegistered',
label: 'ثبت نام دوره',
category: 'اطلاع‌رسانی',
defaultTemplateId: '910210',
envKey: 'SMS_TEMPLATE_CLASS_REGISTERED',
defaultText: ` کارآموز عزیز، #FULLNAME#، ثبت نام شما در دوره #CLASS# انجام شد.
هر هفته #CLASSDAYS#، #CLASSTIME#
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'className', label: 'نام کلاس', defaultName: 'CLASS' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'CLASSDAYS' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'CLASSTIME' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
]
},
{
key: 'classPlanProfessor',
label: 'برنامه کلاس به استاد',
category: 'اطلاع‌رسانی',
defaultTemplateId: '',
envKey: 'SMS_TEMPLATE_CLASS_PLAN_PROFESSOR',
defaultText: `با سلام و وقت بخیر، استاد #professorName#،
برنامه کلاس #className# شما به شرح زیر می باشد:
#classDays#، #classTimes#
از #classStartDate# الی #classEndDate#`,
slots: [
{ key: 'professorName', label: 'نام استاد', defaultName: 'professorName' },
{ key: 'className', label: 'نام کلاس', defaultName: 'className' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'classTimes', label: 'ساعت برگزاری', defaultName: 'classTimes' },
{ key: 'classStartDate', label: 'تاریخ شروع', defaultName: 'classStartDate' },
{ key: 'classEndDate', label: 'تاریخ پایان', defaultName: 'classEndDate' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'passwordReset',
label: 'بازنشانی رمز عبور',
category: 'اطلاع‌رسانی',
defaultTemplateId: '250973',
envKey: 'SMS_TEMPLATE_PASSWORD_RESET',
defaultText: ` کارآموز گرامی، رمز عبور حساب کاربری شما در وبسایت آموزشگاه گام نو بازنشانی شد.
نام کاربری: #USER#
رمز عبور: #PASSWORD#
game-no.ir`,
slots: [
{ key: 'username', label: 'نام کاربری', defaultName: 'USER' },
{ key: 'password', label: 'رمز عبور', defaultName: 'PASSWORD' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'name' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
]
},
{
key: 'classReminder',
label: 'یادآوری کلاس',
category: 'اطلاع‌رسانی',
defaultTemplateId: '',
envKey: 'SMS_TEMPLATE_CLASS_REMINDER',
defaultText: ` #FULLNAME# عزیز، یادآوری کلاس «#CLASSNAME#» در تاریخ #SESSIONDATE# ساعت #CLASSTIME#.
مکان: #PLACE#
آموزشگاه گام نو
game-no.ir`,
slots: [
{ key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
{ key: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
{ key: 'classTime', label: 'ساعت کلاس', defaultName: 'CLASSTIME' },
{ key: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' },
{ key: 'place', label: 'مکان', defaultName: 'PLACE' },
{ key: 'fullName', label: 'نام کاربر', defaultName: 'FULLNAME' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
{ key: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
{ key: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
{ key: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
]
},
{ {
key: 'classRequestApproved', key: 'classRequestApproved',
label: 'تأیید درخواست تشکیل کلاس', label: 'تأیید درخواست تشکیل کلاس',
category: 'ثبت‌نام و درخواست‌ها',
defaultTemplateId: '',
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_APPROVED', envKey: 'SMS_TEMPLATE_CLASS_REQUEST_APPROVED',
defaultEnabled: false, defaultEnabled: false,
defaultText: ` #FULLNAME# عزیز، درخواست شما برای تشکیل کلاس #COURSENAME# تأیید شد.
آموزشگاه گام نو
game-no.ir`,
slots: [ slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, { key: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' },
{ key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }, { key: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
] ]
@@ -159,12 +270,19 @@ const SMS_TEMPLATE_DEFS = [
{ {
key: 'classRequestRejected', key: 'classRequestRejected',
label: 'رد درخواست ثبت‌نام', label: 'رد درخواست ثبت‌نام',
category: 'ثبت‌نام و درخواست‌ها',
defaultTemplateId: '',
envKey: 'SMS_TEMPLATE_CLASS_REQUEST_REJECTED', envKey: 'SMS_TEMPLATE_CLASS_REQUEST_REJECTED',
defaultEnabled: false, defaultEnabled: false,
defaultText: ` #FULLNAME# عزیز، درخواست ثبت‌نام شما برای دوره #COURSENAME# مورد پذیرش قرار نگرفت.
دلیل: #REASON#
آموزشگاه گام نو
game-no.ir`,
slots: [ slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' }, { key: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' },
{ key: 'reason', label: 'دلیل', defaultName: 'reason' }, { key: 'reason', label: 'دلیل', defaultName: 'REASON' },
{ key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' }, { key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
] ]
@@ -172,24 +290,18 @@ const SMS_TEMPLATE_DEFS = [
{ {
key: 'pendingRegistration', key: 'pendingRegistration',
label: 'دریافت درخواست ثبت‌نام', label: 'دریافت درخواست ثبت‌نام',
category: 'ثبت‌نام و درخواست‌ها',
defaultTemplateId: '',
envKey: 'SMS_TEMPLATE_PENDING_REGISTRATION', envKey: 'SMS_TEMPLATE_PENDING_REGISTRATION',
defaultEnabled: false, defaultEnabled: false,
defaultText: ` #FULLNAME# عزیز، درخواست ثبت‌نام شما در کلاس #CLASSNAME# با کد پیگیری #REGISTRATIONCODE# دریافت شد.
آموزشگاه گام نو
game-no.ir`,
slots: [ slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' }, { key: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
{ key: 'className', label: 'نام کلاس', defaultName: 'className' }, { key: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
{ key: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' }, { key: 'registrationCode', label: 'کد درخواست', defaultName: 'REGISTRATIONCODE' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
]
},
{
key: 'certificateIssued',
label: 'صدور گواهینامه',
envKey: 'SMS_TEMPLATE_CERTIFICATE_ISSUED',
slots: [
{ key: 'fullName', label: 'نام کارآموز', defaultName: 'fullName' },
{ key: 'certificateTitle', label: 'عنوان گواهینامه', defaultName: 'certificateTitle' },
{ key: 'certificateCode', label: 'کد گواهینامه', defaultName: 'certificateCode' },
{ key: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
{ key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' } { key: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
] ]
} }
@@ -214,12 +326,15 @@ const sanitizeVariableName = (value) => {
if (value == null) return ''; if (value == null) return '';
const name = String(value).trim().replace(/^#+|#+$/g, '').trim(); const name = String(value).trim().replace(/^#+|#+$/g, '').trim();
if (!name) return ''; if (!name) return '';
// Check for dangerous injection or control chars
if (/[\r\n\t\0<>"'`]/.test(name)) return null; if (/[\r\n\t\0<>"'`]/.test(name)) return null;
if (name.length > 100) return null; if (name.length > 100) return null;
return name; return name;
}; };
const escapeRegExp = (string) => {
return String(string).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const defaultVariablesList = (def) => { const defaultVariablesList = (def) => {
return (def?.slots || []).map((slot) => ({ return (def?.slots || []).map((slot) => ({
slot: slot.key, slot: slot.key,
@@ -229,7 +344,9 @@ const defaultVariablesList = (def) => {
const emptyTemplateEntry = (def) => ({ const emptyTemplateEntry = (def) => ({
enabled: def ? (def.defaultEnabled !== false) : true, enabled: def ? (def.defaultEnabled !== false) : true,
templateId: '', templateId: def?.defaultTemplateId || '',
category: def?.category || 'اطلاع‌رسانی',
text: def?.defaultText || '',
variables: defaultVariablesList(def) variables: defaultVariablesList(def)
}); });
@@ -271,10 +388,16 @@ const normalizeVariablesInput = (rawVariables, def) => {
const normalizeStoredEntry = (raw, def) => { const normalizeStoredEntry = (raw, def) => {
const defaultEnabled = def ? (def.defaultEnabled !== false) : true; const defaultEnabled = def ? (def.defaultEnabled !== false) : true;
const defaultText = def?.defaultText || '';
const defaultTemplateId = def?.defaultTemplateId || '';
const defaultCategory = def?.category || 'اطلاع‌رسانی';
if (raw == null || raw === '') { if (raw == null || raw === '') {
return { return {
enabled: defaultEnabled, enabled: defaultEnabled,
templateId: '', templateId: defaultTemplateId,
category: defaultCategory,
text: defaultText,
variables: def ? defaultVariablesList(def) : [] variables: def ? defaultVariablesList(def) : []
}; };
} }
@@ -282,13 +405,17 @@ const normalizeStoredEntry = (raw, def) => {
return { return {
enabled: true, enabled: true,
templateId: String(raw), templateId: String(raw),
category: defaultCategory,
text: defaultText,
variables: def ? defaultVariablesList(def) : [] variables: def ? defaultVariablesList(def) : []
}; };
} }
if (typeof raw !== 'object') { if (typeof raw !== 'object') {
return { return {
enabled: defaultEnabled, enabled: defaultEnabled,
templateId: '', templateId: defaultTemplateId,
category: defaultCategory,
text: defaultText,
variables: def ? defaultVariablesList(def) : [] variables: def ? defaultVariablesList(def) : []
}; };
} }
@@ -301,9 +428,21 @@ const normalizeStoredEntry = (raw, def) => {
? Boolean(raw.enabled) ? Boolean(raw.enabled)
: defaultEnabled; : defaultEnabled;
const templateId = raw.templateId != null && String(raw.templateId).trim() !== ''
? String(raw.templateId).trim()
: defaultTemplateId;
const text = raw.text != null && String(raw.text).trim() !== ''
? String(raw.text)
: defaultText;
const category = raw.category || defaultCategory;
return { return {
enabled, enabled,
templateId: raw.templateId != null ? String(raw.templateId) : '', templateId,
category,
text,
variables variables
}; };
}; };
@@ -315,15 +454,22 @@ const parseIncomingVariables = (rawVariables, def) => {
const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => { const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => {
const storedEntry = normalizeStoredEntry(storedRaw, def); const storedEntry = normalizeStoredEntry(storedRaw, def);
let templateId = storedEntry.templateId || envFallbackFor(def); let templateId = storedEntry.templateId || envFallbackFor(def) || def?.defaultTemplateId || '';
let variables = storedEntry.variables; let variables = storedEntry.variables;
let enabled = storedEntry.enabled; let enabled = storedEntry.enabled;
let text = storedEntry.text || def?.defaultText || '';
let category = storedEntry.category || def?.category || 'اطلاع‌رسانی';
if (incomingRaw) { if (incomingRaw) {
if (incomingRaw.enabled !== undefined) { if (incomingRaw.enabled !== undefined) {
enabled = Boolean(incomingRaw.enabled); enabled = Boolean(incomingRaw.enabled);
} }
if (incomingRaw.text !== undefined) {
text = String(incomingRaw.text || '');
}
if (incomingRaw.category !== undefined) {
category = String(incomingRaw.category || '');
}
if (incomingRaw.templateId !== undefined) { if (incomingRaw.templateId !== undefined) {
const sanitized = sanitizeTemplateId(incomingRaw.templateId); const sanitized = sanitizeTemplateId(incomingRaw.templateId);
if (sanitized === null) { if (sanitized === null) {
@@ -334,7 +480,6 @@ const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => {
} }
templateId = sanitized; templateId = sanitized;
} }
if (incomingRaw.variables !== undefined) { if (incomingRaw.variables !== undefined) {
const parsed = parseIncomingVariables(incomingRaw.variables, def) || []; const parsed = parseIncomingVariables(incomingRaw.variables, def) || [];
for (const item of parsed) { for (const item of parsed) {
@@ -350,12 +495,14 @@ const mergeTemplateEntry = (def, storedRaw, incomingRaw = null) => {
variables = parsed; variables = parsed;
} }
} else if (!storedEntry.templateId) { } else if (!storedEntry.templateId) {
templateId = envFallbackFor(def); templateId = envFallbackFor(def) || def?.defaultTemplateId || '';
} }
return { return {
enabled, enabled,
templateId, templateId,
category,
text,
variables: resolveVariablesList(def, variables) variables: resolveVariablesList(def, variables)
}; };
}; };
@@ -382,13 +529,33 @@ const resolveVariablesList = (def, storedVariables) => {
.filter(Boolean); .filter(Boolean);
}; };
const isPriceSlot = (slot = '', name = '') => {
const s = String(slot).toLowerCase();
const n = String(name).toLowerCase();
return s.includes('amount') || s.includes('price') || s.includes('tuition') || s.includes('fee')
|| n.includes('amount') || n.includes('price') || n.includes('tuition') || n.includes('fee') || n.includes('cost');
};
const formatPriceValue = (val) => {
if (val === null || val === undefined || val === '') return '';
const str = String(val).trim();
const rawNum = Number(str.replace(/,/g, ''));
if (!Number.isNaN(rawNum) && Number.isFinite(rawNum)) {
return rawNum.toLocaleString('en-US');
}
return str;
};
const buildSmsParameters = (variables, valuesBySlot = {}) => { const buildSmsParameters = (variables, valuesBySlot = {}) => {
return normalizeVariablesInput(variables, null) return normalizeVariablesInput(variables, null)
.map((item) => { .map((item) => {
const name = sanitizeVariableName(item?.name); const name = sanitizeVariableName(item?.name);
if (!name) return null; if (!name) return null;
const slot = item?.slot; const slot = item?.slot;
const value = slot ? valuesBySlot?.[slot] : ''; let value = slot ? valuesBySlot?.[slot] : '';
if (isPriceSlot(slot, name) && value != null && value !== '') {
value = formatPriceValue(value);
}
return { return {
name, name,
value: String(value ?? '') value: String(value ?? '')
@@ -397,6 +564,46 @@ const buildSmsParameters = (variables, valuesBySlot = {}) => {
.filter(Boolean); .filter(Boolean);
}; };
/**
* Renders the exact text of an SMS template by replacing variable placeholders (e.g. #FULLNAME# or #className#)
* with the corresponding values provided in slotValues.
*/
const renderSmsText = (templateText, variables = [], slotValues = {}) => {
if (!templateText || typeof templateText !== 'string') return '';
let result = templateText;
// 1. Replace defined template variables (mapped by slot and variable name in sms.ir)
if (Array.isArray(variables)) {
for (const item of variables) {
if (!item || !item.name) continue;
const varName = String(item.name).trim().replace(/^#+|#+$/g, '');
if (!varName) continue;
const slotKey = item.slot;
let val = slotKey !== undefined && slotValues[slotKey] !== undefined ? slotValues[slotKey] : '';
if (isPriceSlot(slotKey, varName) && val != null && val !== '') {
val = formatPriceValue(val);
}
const pattern = new RegExp(`#${escapeRegExp(varName)}#`, 'gi');
result = result.replace(pattern, String(val ?? ''));
}
}
// 2. Direct slot replacements (e.g., #professorName#, #className#, #amount#, #fullName#)
if (slotValues && typeof slotValues === 'object') {
for (const [slotKey, rawVal] of Object.entries(slotValues)) {
if (!slotKey) continue;
let val = rawVal;
if (isPriceSlot(slotKey, slotKey) && val != null && val !== '') {
val = formatPriceValue(val);
}
const pattern = new RegExp(`#${escapeRegExp(slotKey)}#`, 'gi');
result = result.replace(pattern, String(val ?? ''));
}
}
return result;
};
module.exports = { module.exports = {
SMS_TEMPLATE_DEFS, SMS_TEMPLATE_DEFS,
SMS_TEMPLATE_KEYS, SMS_TEMPLATE_KEYS,
@@ -409,5 +616,7 @@ module.exports = {
parseIncomingVariables, parseIncomingVariables,
mergeTemplateEntry, mergeTemplateEntry,
resolveVariablesList, resolveVariablesList,
buildSmsParameters buildSmsParameters,
renderSmsText,
formatPriceValue
}; };
@@ -0,0 +1,24 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const userService = require('./userService');
const professorService = require('../professors/professorService');
describe('Student Profile and Professor Promotion Service Exports', () => {
it('exports getUserFullProfile and promoteToProfessor in userService', () => {
assert.equal(typeof userService.getUserFullProfile, 'function');
assert.equal(typeof userService.promoteToProfessor, 'function');
});
it('exports createProfessorFromUser in professorService', () => {
assert.equal(typeof professorService.createProfessorFromUser, 'function');
});
it('throws validation error when userId is missing in createProfessorFromUser', async () => {
await assert.rejects(
() => professorService.createProfessorFromUser(null),
(err) => err.errorCode === 'VALIDATION_FAILED' || err.statusCode === 400
);
});
});
+10
View File
@@ -36,6 +36,16 @@ exports.getOne = catchAsync(async (req, res, next) => {
return successResponse(res, 200, 'User retrieved successfully', user); return successResponse(res, 200, 'User retrieved successfully', user);
}); });
exports.getFullProfile = catchAsync(async (req, res, next) => {
const profile = await userService.getUserFullProfile(req.params.id);
return successResponse(res, 200, 'User full profile retrieved successfully', profile);
});
exports.promoteToProfessor = catchAsync(async (req, res, next) => {
const result = await userService.promoteToProfessor(req.params.id, req.body);
return successResponse(res, 200, 'User promoted to professor successfully', result);
});
exports.getAll = catchAsync(async (req, res, next) => { exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await userService.getAllUsers(req.query); const { data, meta } = await userService.getAllUsers(req.query);
return listResponse(res, 200, data, meta); return listResponse(res, 200, data, meta);
+2
View File
@@ -24,7 +24,9 @@ router.post('/admin/create', authMiddleware, perm.requires(PERMISSIONS.USERS_CRE
router.get('/admin/get-all', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getAll); router.get('/admin/get-all', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getAll);
router.get('/admin/search', authMiddleware, perm.requires(PERMISSIONS.USERS_SEARCH), userController.search); router.get('/admin/search', authMiddleware, perm.requires(PERMISSIONS.USERS_SEARCH), userController.search);
router.get('/admin/get-one/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getOne); router.get('/admin/get-one/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getOne);
router.get('/admin/:id/full-profile', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getFullProfile);
router.put('/admin/update/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), validateUpdateUser, userController.update); router.put('/admin/update/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), validateUpdateUser, userController.update);
router.post('/admin/:id/promote-to-professor', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), userController.promoteToProfessor);
router.post('/admin/:id/reset-password-sms', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), userController.resetPasswordAndSendSms); router.post('/admin/:id/reset-password-sms', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), userController.resetPasswordAndSendSms);
router.delete('/admin/delete/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_DELETE), userController.delete); router.delete('/admin/delete/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_DELETE), userController.delete);
router.post('/admin/:userId/enroll', authMiddleware, perm.requires(PERMISSIONS.USERS_ENROLL), validateEnroll, userController.enroll); router.post('/admin/:userId/enroll', authMiddleware, perm.requires(PERMISSIONS.USERS_ENROLL), validateEnroll, userController.enroll);
+248 -1
View File
@@ -4,6 +4,15 @@
const User = require('./userModel'); const User = require('./userModel');
const Role = require('../roles/roleModel'); const Role = require('../roles/roleModel');
const Class = require('../classes/classModel'); const Class = require('../classes/classModel');
const Session = require('../sessions/sessionModel');
const Payment = require('../payments/paymentModel');
const Transaction = require('../payments/transactionModel');
const Certificate = require('../certificates/certificateModel');
const Document = require('../documents/documentModel');
const Waitlist = require('../waitlist/waitlistModel');
const Notification = require('../notifications/notificationModel');
const ActivityLog = require('../activityLogs/activityLogModel');
const professorService = require('../professors/professorService');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const AppError = require('../../utils/AppError'); const AppError = require('../../utils/AppError');
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination'); const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
@@ -363,14 +372,252 @@ const enrollUserInCourse = async (userId, courseId) => {
return User.findById(userId).select(SAFE_FIELDS).populate('courses').lean(); return User.findById(userId).select(SAFE_FIELDS).populate('courses').lean();
}; };
const getUserFullProfile = async (id) => {
const user = await User.findById(id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean();
if (!user) throw new AppError('USER_NOT_FOUND');
// 1. Enrolled Classes
const classes = await Class.find({ students: id, isDeleted: { $ne: true } })
.populate('course', 'title type price hoursPerSection')
.populate('professor', 'name surname phoneNumber email')
.sort({ createdAt: -1 })
.lean();
const classIds = classes.map((c) => c._id);
// 2. All Sessions for user's classes or where user is in attendanceList
const sessions = await Session.find({
$or: [
{ class: { $in: classIds } },
{ 'attendanceList.user': id }
],
isDeleted: { $ne: true }
})
.populate('course', 'title')
.populate('class', 'name')
.populate('professor', 'name surname')
.populate('attendanceList.recordedBy', 'name username')
.sort({ day: -1, startTime: -1 })
.lean();
// Map attendance records specifically for this student
let presentCount = 0;
let absentCount = 0;
let lateCount = 0;
let excusedCount = 0;
const userAttendances = sessions.map((sess) => {
const record = (sess.attendanceList || []).find((r) => String(r.user) === String(id));
const status = record ? record.status : 'scheduled';
if (status === 'present') presentCount++;
else if (status === 'absent') absentCount++;
else if (status === 'late') lateCount++;
else if (status === 'excused') excusedCount++;
return {
_id: sess._id,
sessionId: sess._id,
day: sess.day,
startTime: sess.startTime,
endTime: sess.endTime,
place: sess.place,
topic: sess.topic,
sessionStatus: sess.status,
course: sess.course,
class: sess.class,
professor: sess.professor,
attendanceStatus: status,
attendanceNote: record?.note || '',
recordedBy: record?.recordedBy || null
};
});
const totalRecordedAttendance = presentCount + absentCount + lateCount + excusedCount;
const attendanceRate = totalRecordedAttendance > 0
? Math.round(((presentCount + lateCount) / totalRecordedAttendance) * 100)
: 0;
// 3. Payments & Invoices
const payments = await Payment.find({ user: id, isDeleted: { $ne: true } })
.populate('classes', 'name tuitionFee')
.populate('course', 'title')
.sort({ createdAt: -1 })
.lean();
const paymentIds = payments.map((p) => p._id);
// 4. Bank Transactions
const transactions = await Transaction.find({
$or: [
{ user: id },
{ payment: { $in: paymentIds } }
]
})
.populate('payment', 'uniqueCode amount status')
.populate('recordedBy', 'name username')
.sort({ date: -1, createdAt: -1 })
.lean();
// 5. Financial Summary Calculations
let totalTuition = 0;
let totalDiscount = 0;
let totalPayable = 0;
let totalPaid = 0;
let overdueAmount = 0;
let pendingAmount = 0;
const paymentStatusMap = {
paid: { count: 0, amount: 0 },
partial: { count: 0, amount: 0 },
pending: { count: 0, amount: 0 },
overdue: { count: 0, amount: 0 },
cancelled: { count: 0, amount: 0 },
reverted: { count: 0, amount: 0 }
};
payments.forEach((p) => {
const payable = Math.max(0, (p.amount || 0) - (p.discount || 0));
totalTuition += (p.amount || 0);
totalDiscount += (p.discount || 0);
totalPayable += payable;
totalPaid += (p.paidAmount || 0);
const st = p.status || 'pending';
if (paymentStatusMap[st]) {
paymentStatusMap[st].count++;
paymentStatusMap[st].amount += payable;
}
if (st === 'overdue') {
overdueAmount += Math.max(0, payable - (p.paidAmount || 0));
} else if (st === 'pending' || st === 'partial') {
pendingAmount += Math.max(0, payable - (p.paidAmount || 0));
}
});
const remainingDebt = Math.max(0, totalPayable - totalPaid);
const financialSummary = {
totalTuition,
totalDiscount,
totalPayable,
totalPaid,
remainingDebt,
overdueAmount,
pendingAmount,
totalInvoicesCount: payments.length,
totalTransactionsCount: transactions.length
};
// Monthly transaction timeline for charts
const monthlyTimelineMap = {};
transactions.forEach((t) => {
if (t.status !== 'cancelled' && t.status !== 'reverted') {
const d = t.date || t.createdAt;
if (d) {
const monthKey = new Date(d).toISOString().slice(0, 7); // YYYY-MM
if (!monthlyTimelineMap[monthKey]) {
monthlyTimelineMap[monthKey] = { month: monthKey, amount: 0, count: 0 };
}
monthlyTimelineMap[monthKey].amount += (t.amount || 0);
monthlyTimelineMap[monthKey].count++;
}
}
});
const monthlyTransactions = Object.values(monthlyTimelineMap).sort((a, b) => a.month.localeCompare(b.month));
// 6. Certificates & Documents
const certificates = await Certificate.find({ user: id })
.populate('course', 'title')
.sort({ issuedAt: -1 })
.lean();
const documents = await Document.find({ user: id })
.populate('uploadedBy', 'name username')
.sort({ createdAt: -1 })
.lean();
// 7. Waitlist Entries
const waitlist = await Waitlist.find({ user: id, isDeleted: { $ne: true } })
.populate('course', 'title type price')
.populate('class', 'name startDate')
.sort({ createdAt: -1 })
.lean();
// 8. Notifications
const notifications = await Notification.find({ user: id })
.sort({ createdAt: -1 })
.limit(50)
.lean();
// 9. Activity Logs
const activityLogs = await ActivityLog.find({
$or: [
{ actor: id },
{ resourceId: String(id) },
{ 'metadata.userId': String(id) }
]
})
.sort({ createdAt: -1 })
.limit(50)
.lean();
const chartsData = {
attendance: {
present: presentCount,
absent: absentCount,
late: lateCount,
excused: excusedCount,
total: totalRecordedAttendance,
attendanceRate
},
paymentsBreakdown: [
{ status: 'paid', count: paymentStatusMap.paid.count, amount: paymentStatusMap.paid.amount },
{ status: 'partial', count: paymentStatusMap.partial.count, amount: paymentStatusMap.partial.amount },
{ status: 'pending', count: paymentStatusMap.pending.count, amount: paymentStatusMap.pending.amount },
{ status: 'overdue', count: paymentStatusMap.overdue.count, amount: paymentStatusMap.overdue.amount }
],
financialSummary,
monthlyTransactions
};
return {
user: {
...user,
nationalId: user.nationalIdCode,
registeredClassesCount: classes.length
},
classes,
sessions,
attendances: userAttendances,
payments,
transactions,
financialSummary,
attendanceSummary: chartsData.attendance,
chartsData,
certificates,
documents,
waitlist,
notifications,
activityLogs
};
};
const promoteToProfessor = async (id, data = {}) => {
return professorService.createProfessorFromUser(id, data);
};
module.exports = { module.exports = {
signUp, signUp,
getUserById, getUserById,
getUserFullProfile,
getAllUsers, getAllUsers,
searchUsers, searchUsers,
createUserAdmin, createUserAdmin,
updateUser, updateUser,
deleteUser, deleteUser,
resetPasswordAndSendSms, resetPasswordAndSendSms,
enrollUserInCourse enrollUserInCourse,
promoteToProfessor
}; };
+56
View File
@@ -0,0 +1,56 @@
// /components/waitlist/waitlistController.js
'use strict';
const catchAsync = require('../../utils/catchAsync');
const waitlistService = require('./waitlistService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await waitlistService.getAll(req.query);
return listResponse(res, 200, data, meta);
});
exports.getOne = catchAsync(async (req, res) => {
const item = await waitlistService.getOne(req.params.id);
return successResponse(res, 200, 'Waitlist item retrieved successfully', item);
});
exports.create = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const item = await waitlistService.create(req.body, actorId);
return successResponse(res, 201, 'Student added to waitlist successfully', item);
});
exports.update = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const item = await waitlistService.update(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Waitlist item updated successfully', item);
});
exports.assignClass = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const item = await waitlistService.assignToClass(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Student assigned to class successfully', item);
});
exports.revert = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const item = await waitlistService.revert(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Waitlist registration reverted successfully', item);
});
exports.cancel = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const item = await waitlistService.cancel(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Waitlist registration cancelled successfully', item);
});
exports.delete = catchAsync(async (req, res) => {
await waitlistService.remove(req.params.id);
return successResponse(res, 200, 'Waitlist item deleted successfully');
});
exports.getStats = catchAsync(async (req, res) => {
const stats = await waitlistService.getStats();
return successResponse(res, 200, 'Waitlist stats retrieved successfully', stats);
});
+66
View File
@@ -0,0 +1,66 @@
// /components/waitlist/waitlistModel.js
'use strict';
const mongoose = require('mongoose');
const uniqueCodePlugin = require('../../utils/mongooseUniqueCodePlugin');
const waitlistSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course',
required: true,
index: true
},
payment: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Payment',
index: true
},
class: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Class',
default: null,
index: true
},
status: {
type: String,
enum: ['waiting', 'enrolled', 'cancelled', 'reverted'],
default: 'waiting',
index: true
},
adminNotes: {
type: [String],
default: []
},
registeredAt: {
type: Date,
default: Date.now
},
assignedAt: {
type: Date,
default: null
},
isDeleted: {
type: Boolean,
default: false,
index: true
},
deletedAt: {
type: Date,
default: null
}
}, {
timestamps: true,
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
waitlistSchema.plugin(uniqueCodePlugin);
module.exports = mongoose.model('Waitlist', waitlistSchema);
+24
View File
@@ -0,0 +1,24 @@
// /components/waitlist/waitlistRoutes.js
'use strict';
const express = require('express');
const waitlistController = require('./waitlistController');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
router.get('/admin/get-all', perm.requires(PERMISSIONS.WAITLIST_READ), waitlistController.getAll);
router.get('/admin/stats', perm.requires(PERMISSIONS.WAITLIST_READ), waitlistController.getStats);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.WAITLIST_READ), waitlistController.getOne);
router.post('/admin/create', perm.requires(PERMISSIONS.WAITLIST_CREATE), waitlistController.create);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.update);
router.post('/admin/:id/assign-class', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.assignClass);
router.post('/admin/:id/revert', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.revert);
router.post('/admin/:id/cancel', perm.requires(PERMISSIONS.WAITLIST_UPDATE), waitlistController.cancel);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.WAITLIST_DELETE), waitlistController.delete);
module.exports = router;
+303
View File
@@ -0,0 +1,303 @@
// /components/waitlist/waitlistService.js
'use strict';
const Waitlist = require('./waitlistModel');
const User = require('../users/userModel');
const Course = require('../courses/courseModel');
const Class = require('../classes/classModel');
const Payment = require('../payments/paymentModel');
const Transaction = require('../payments/transactionModel');
const AppError = require('../../utils/AppError');
const { calculateMeta, escapeRegex, getSearchTerm } = require('../../utils/pagination');
const {
getPayableAmount,
normalizeDiscount,
sanitizeNotes,
sumPaidTransactions
} = require('../../utils/paymentAmount');
const paymentService = require('../payments/paymentService');
const populateWaitlist = (query) => query
.populate({ path: 'user', select: 'name phoneNumber email nationalIdCode' })
.populate({ path: 'course', select: 'title price code' })
.populate({ path: 'class', select: 'name tuitionFee startDate days startTime endTime' })
.populate({
path: 'payment',
populate: {
path: 'transactions',
options: { sort: { dueDate: 1, date: 1, createdAt: 1 } }
}
});
const getAll = async (query = {}) => {
const page = parseInt(query.page, 10) || 1;
const limit = Math.min(parseInt(query.limit, 10) || 20, 200);
const skip = (page - 1) * limit;
const filter = {};
if (query.trash === 'true' || query.isDeleted === 'true') {
filter.isDeleted = true;
} else {
filter.isDeleted = { $ne: true };
}
if (query.courseId) filter.course = query.courseId;
if (query.userId) filter.user = query.userId;
if (query.status) filter.status = query.status;
const searchTerm = getSearchTerm(query);
if (searchTerm) {
const searchRegex = new RegExp(escapeRegex(searchTerm), 'i');
const matchedUsers = await User.find({
$or: [
{ name: searchRegex },
{ phoneNumber: searchRegex },
{ nationalIdCode: searchRegex }
]
}).select('_id').lean();
filter.$or = [
{ uniqueCode: searchRegex },
{ adminNotes: searchRegex },
{ user: { $in: matchedUsers.map((u) => u._id) } }
];
}
const [items, total] = await Promise.all([
populateWaitlist(Waitlist.find(filter))
.skip(skip)
.limit(limit)
.sort({ createdAt: -1 })
.lean(),
Waitlist.countDocuments(filter)
]);
return { data: items, meta: calculateMeta(total, page, limit) };
};
const getOne = async (id) => {
const item = await populateWaitlist(Waitlist.findById(id)).lean();
if (!item) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
return item;
};
const create = async (body, actorId = null) => {
const { userId, courseId, amount, discount, dueDate, notes, initialTransaction, adminNotes } = body;
const user = await User.findById(userId || body.user);
if (!user) throw new AppError('USER_NOT_FOUND', {}, 'کاربر مورد نظر یافت نشد.');
const course = await Course.findById(courseId || body.course);
if (!course) throw new AppError('COURSE_NOT_FOUND', {}, 'دوره آموزشی مورد نظر یافت نشد.');
const totalAmount = amount !== undefined && amount !== null && amount !== ''
? Number(amount)
: (course.price || 0);
const totalDiscount = discount !== undefined && discount !== null && discount !== ''
? normalizeDiscount(Number(discount), totalAmount)
: 0;
// Create waiting_list payment
const payment = await Payment.create({
user: user._id,
course: course._id,
classes: [],
amount: totalAmount,
discount: totalDiscount,
dueDate: dueDate ? new Date(dueDate) : undefined,
type: 'waiting_list',
status: 'pending',
notes: sanitizeNotes(notes || `صورتحساب ثبت‌نام لیست انتظار دوره ${course.title}`)
});
// Create initial transaction if supplied
if (initialTransaction && (Number(initialTransaction.amount) > 0 || initialTransaction.status === 'paid')) {
const trxStatus = initialTransaction.status || 'paid';
const trxDate = trxStatus === 'paid' ? (initialTransaction.date ? new Date(initialTransaction.date) : new Date()) : undefined;
const trxDueDate = initialTransaction.dueDate ? new Date(initialTransaction.dueDate) : (trxDate || new Date());
await Transaction.create({
payment: payment._id,
user: user._id,
amount: Number(initialTransaction.amount) || (totalAmount - totalDiscount),
status: trxStatus,
method: initialTransaction.method || 'card',
receiptNumber: initialTransaction.receiptNumber ? String(initialTransaction.receiptNumber) : '',
date: trxDate,
dueDate: trxDueDate,
notes: sanitizeNotes(initialTransaction.notes || 'پرداخت بیعانه / ثبت‌نام لیست انتظار'),
recordedBy: actorId
});
await paymentService.refreshPaymentTotals(payment);
}
const notesList = [];
if (Array.isArray(adminNotes)) {
notesList.push(...adminNotes.filter(Boolean));
} else if (notes) {
notesList.push(String(notes));
}
const waitlist = await Waitlist.create({
user: user._id,
course: course._id,
payment: payment._id,
status: 'waiting',
adminNotes: notesList,
registeredAt: body.registeredAt ? new Date(body.registeredAt) : new Date()
});
return getOne(waitlist._id);
};
const update = async (id, body) => {
const waitlist = await Waitlist.findById(id);
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
if (body.adminNotes !== undefined) {
waitlist.adminNotes = Array.isArray(body.adminNotes)
? body.adminNotes.filter(Boolean)
: [String(body.adminNotes)];
}
if (body.status !== undefined) {
waitlist.status = body.status;
}
if (body.registeredAt !== undefined) {
waitlist.registeredAt = new Date(body.registeredAt);
}
await waitlist.save();
return getOne(waitlist._id);
};
const assignToClass = async (id, { classId }, actorId = null) => {
const waitlist = await Waitlist.findById(id);
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
const targetClass = await Class.findById(classId).populate('course', 'title price');
if (!targetClass) throw new AppError('CLASS_NOT_FOUND', {}, 'کلاس مورد نظر یافت نشد.');
// 1. Add user to class students list if not present
const studentIdStr = String(waitlist.user);
const alreadyEnrolled = (targetClass.students || []).some((s) => String(s) === studentIdStr);
if (!alreadyEnrolled) {
targetClass.students.push(waitlist.user);
await targetClass.save();
}
// 2. Transition Payment to regular and link class
if (waitlist.payment) {
const payment = await Payment.findById(waitlist.payment);
if (payment) {
payment.classes = [targetClass._id];
payment.type = 'regular';
await payment.save();
await paymentService.refreshPaymentTotals(payment);
}
}
// 3. Mark waitlist status as enrolled
waitlist.class = targetClass._id;
waitlist.status = 'enrolled';
waitlist.assignedAt = new Date();
await waitlist.save();
return getOne(waitlist._id);
};
const revert = async (id, { notes } = {}, actorId = null) => {
const waitlist = await Waitlist.findById(id);
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
waitlist.status = 'reverted';
if (notes) {
waitlist.adminNotes.push(`استرداد: ${notes}`);
}
await waitlist.save();
if (waitlist.payment) {
const payment = await Payment.findById(waitlist.payment);
if (payment) {
payment.status = 'reverted';
await payment.save();
const transactions = await Transaction.find({ payment: payment._id });
for (const trx of transactions) {
trx.status = 'reverted';
if (actorId) trx.recordedBy = actorId;
await trx.save();
}
await paymentService.refreshPaymentTotals(payment);
}
}
return getOne(waitlist._id);
};
const cancel = async (id, { notes } = {}, actorId = null) => {
const waitlist = await Waitlist.findById(id);
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
waitlist.status = 'cancelled';
if (notes) {
waitlist.adminNotes.push(`لغو: ${notes}`);
}
await waitlist.save();
if (waitlist.payment) {
const payment = await Payment.findById(waitlist.payment);
if (payment) {
payment.status = 'cancelled';
await payment.save();
const transactions = await Transaction.find({ payment: payment._id });
for (const trx of transactions) {
trx.status = 'cancelled';
if (actorId) trx.recordedBy = actorId;
await trx.save();
}
await paymentService.refreshPaymentTotals(payment);
}
}
return getOne(waitlist._id);
};
const remove = async (id) => {
const waitlist = await Waitlist.findById(id);
if (!waitlist) throw new AppError('WAITLIST_NOT_FOUND', {}, 'موردی در لیست انتظار یافت نشد.');
waitlist.isDeleted = true;
waitlist.deletedAt = new Date();
await waitlist.save();
return { success: true };
};
const getStats = async () => {
const [total, waiting, enrolled, reverted, cancelled] = await Promise.all([
Waitlist.countDocuments({ isDeleted: { $ne: true } }),
Waitlist.countDocuments({ status: 'waiting', isDeleted: { $ne: true } }),
Waitlist.countDocuments({ status: 'enrolled', isDeleted: { $ne: true } }),
Waitlist.countDocuments({ status: 'reverted', isDeleted: { $ne: true } }),
Waitlist.countDocuments({ status: 'cancelled', isDeleted: { $ne: true } })
]);
return { total, waiting, enrolled, reverted, cancelled };
};
module.exports = {
getAll,
getOne,
create,
update,
assignToClass,
revert,
cancel,
remove,
getStats
};
@@ -0,0 +1,35 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const waitlistService = require('./waitlistService');
const Waitlist = require('./waitlistModel');
describe('Waitlist Service and Schema', () => {
it('exports all expected service methods', () => {
assert.equal(typeof waitlistService.getAll, 'function');
assert.equal(typeof waitlistService.getOne, 'function');
assert.equal(typeof waitlistService.create, 'function');
assert.equal(typeof waitlistService.update, 'function');
assert.equal(typeof waitlistService.assignToClass, 'function');
assert.equal(typeof waitlistService.revert, 'function');
assert.equal(typeof waitlistService.cancel, 'function');
assert.equal(typeof waitlistService.remove, 'function');
assert.equal(typeof waitlistService.getStats, 'function');
});
it('has valid schema paths in Waitlist model', () => {
assert.ok(Waitlist.schema.path('user'));
assert.ok(Waitlist.schema.path('course'));
assert.ok(Waitlist.schema.path('payment'));
assert.ok(Waitlist.schema.path('class'));
assert.ok(Waitlist.schema.path('status'));
assert.ok(Waitlist.schema.path('isDeleted'));
assert.ok(Waitlist.schema.path('deletedAt'));
const statusPath = Waitlist.schema.path('status');
assert.deepEqual(statusPath.enumValues, ['waiting', 'enrolled', 'cancelled', 'reverted']);
assert.equal(statusPath.defaultValue, 'waiting');
});
});
+13 -1
View File
@@ -99,7 +99,19 @@ const PERMISSIONS = {
EXPENSES_DELETE: 'expenses:delete', EXPENSES_DELETE: 'expenses:delete',
// Financial reports (professor share, class profitability, date-range analytics) // Financial reports (professor share, class profitability, date-range analytics)
FINANCIAL_REPORTS_READ: 'financial_reports:read' FINANCIAL_REPORTS_READ: 'financial_reports:read',
// Waiting list permissions
WAITLIST_CREATE: 'waitlist:create',
WAITLIST_READ: 'waitlist:read',
WAITLIST_UPDATE: 'waitlist:update',
WAITLIST_DELETE: 'waitlist:delete',
// Employee timing / attendance permissions
EMPLOYEE_TIMINGS_CREATE: 'employee_timings:create',
EMPLOYEE_TIMINGS_READ: 'employee_timings:read',
EMPLOYEE_TIMINGS_UPDATE: 'employee_timings:update',
EMPLOYEE_TIMINGS_DELETE: 'employee_timings:delete'
}; };
const ALL_PERMISSIONS = Object.values(PERMISSIONS); const ALL_PERMISSIONS = Object.values(PERMISSIONS);
+3 -2
View File
@@ -7,7 +7,7 @@ const Course = require('../components/courses/courseModel');
const eventEmitter = require('../events/eventEmitter'); const eventEmitter = require('../events/eventEmitter');
const EVENT_NAMES = require('../constants/eventNames'); const EVENT_NAMES = require('../constants/eventNames');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getPayableAmount } = require('../utils/paymentAmount'); const { getPayableAmount, formatPrice } = require('../utils/paymentAmount');
const { notifyAction } = require('../utils/actionNotify'); const { notifyAction } = require('../utils/actionNotify');
const { sendPaymentReminderSms } = require('../utils/senders/smsMessages'); const { sendPaymentReminderSms } = require('../utils/senders/smsMessages');
const { resolveNotifyFlags } = require('../utils/notifyResolver'); const { resolveNotifyFlags } = require('../utils/notifyResolver');
@@ -30,6 +30,7 @@ const notifyPaymentReminder = async (payment, user) => {
} }
const amountDue = getPayableAmount(payment) - (payment.paidAmount || 0); const amountDue = getPayableAmount(payment) - (payment.paidAmount || 0);
const formattedAmountDue = formatPrice(amountDue);
await notifyAction({ await notifyAction({
actionKey: 'paymentReminder', actionKey: 'paymentReminder',
@@ -37,7 +38,7 @@ const notifyPaymentReminder = async (payment, user) => {
phoneNumber: user.phoneNumber, phoneNumber: user.phoneNumber,
email: user.email, email: user.email,
subject: 'یادآوری سررسید پرداخت', subject: 'یادآوری سررسید پرداخت',
body: `یادآوری: مبلغ ${amountDue} تومان تا ${formatDueDate(payment.dueDate)} سررسید دارد. کد صورتحساب: ${payment.uniqueCode || ''}`, body: `یادآوری: مبلغ ${formattedAmountDue} تومان تا ${formatDueDate(payment.dueDate)} سررسید دارد. کد صورتحساب: ${payment.uniqueCode || ''}`,
smsHandler: () => sendPaymentReminderSms(user.phoneNumber, { smsHandler: () => sendPaymentReminderSms(user.phoneNumber, {
fullName: user.name || '', fullName: user.name || '',
amount: amountDue, amount: amountDue,
+1 -1
View File
@@ -7,7 +7,7 @@
"start": "node app.js", "start": "node app.js",
"dev": "nodemon app.js", "dev": "nodemon app.js",
"seed": "node seed.js", "seed": "node seed.js",
"test": "node --test components/classes/classSoftDelete.test.js components/settings/smsTemplates.test.js components/users/passwordReset.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": [ "keywords": [
"express", "express",
+9 -1
View File
@@ -61,7 +61,15 @@ const defaultRoles = [
PERMISSIONS.EXPENSES_CREATE, PERMISSIONS.EXPENSES_CREATE,
PERMISSIONS.EXPENSES_READ, PERMISSIONS.EXPENSES_READ,
PERMISSIONS.EXPENSES_UPDATE, PERMISSIONS.EXPENSES_UPDATE,
PERMISSIONS.FINANCIAL_REPORTS_READ PERMISSIONS.FINANCIAL_REPORTS_READ,
PERMISSIONS.WAITLIST_CREATE,
PERMISSIONS.WAITLIST_READ,
PERMISSIONS.WAITLIST_UPDATE,
PERMISSIONS.WAITLIST_DELETE,
PERMISSIONS.EMPLOYEE_TIMINGS_CREATE,
PERMISSIONS.EMPLOYEE_TIMINGS_READ,
PERMISSIONS.EMPLOYEE_TIMINGS_UPDATE,
PERMISSIONS.EMPLOYEE_TIMINGS_DELETE
], ],
isSystem: true isSystem: true
}, },
+3 -1
View File
@@ -5,6 +5,7 @@ const { sendEmail } = require('./senders/emailSender');
const { sendBaleMessage } = require('./senders/baleBotSender'); const { sendBaleMessage } = require('./senders/baleBotSender');
const { resolveNotifyFlags } = require('./notifyResolver'); const { resolveNotifyFlags } = require('./notifyResolver');
const { findActionDef } = require('../components/settings/notificationActions'); 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. * 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 eventTag = relatedEvent || def?.relatedEvent || actionKey;
const results = {}; 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(); results.sms = await smsHandler();
} }
+57 -3
View File
@@ -70,16 +70,68 @@ const parseImportDate = (raw) => {
if (raw instanceof Date) { if (raw instanceof Date) {
return Number.isNaN(raw.getTime()) ? null : raw; return Number.isNaN(raw.getTime()) ? null : raw;
} }
const text = String(raw).trim(); const text = toEnglishDigits(raw).trim();
if (!text) return null; if (!text) return null;
const gregorian = text.match(/^(\d{4}-\d{2}-\d{2})/);
if (gregorian) return new Date(`${gregorian[1]}T00:00:00.000Z`); if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(text)) {
const d = new Date(text);
return Number.isNaN(d.getTime()) ? null : d;
}
const dateMatch = text.match(/^(\d{3,4})[./\-](\d{1,2})[./\-](\d{1,2})/);
if (dateMatch) {
const year = Number(dateMatch[1]);
const month = Number(dateMatch[2]);
const day = Number(dateMatch[3]);
if (year >= 1200 && year <= 1599) {
const gDate = jalaliToGregorian(year, month, day);
if (gDate) return new Date(`${gDate}T00:00:00.000Z`);
} else if (year >= 1900 && year <= 2200) {
const mStr = String(month).padStart(2, '0');
const dStr = String(day).padStart(2, '0');
return new Date(`${year}-${mStr}-${dStr}T00:00:00.000Z`);
}
}
const jalali = parseJalaliDate(text); const jalali = parseJalaliDate(text);
if (jalali) return new Date(`${jalali}T00:00:00.000Z`); if (jalali) return new Date(`${jalali}T00:00:00.000Z`);
const date = new Date(text); const date = new Date(text);
return Number.isNaN(date.getTime()) ? null : date; return Number.isNaN(date.getTime()) ? null : date;
}; };
const gregorianToJalali = (gy, gm, gd) => {
const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let jy;
if (gy > 1600) {
jy = 979;
gy -= 1600;
} else {
jy = 0;
gy -= 621;
}
const gy2 = gm > 2 ? gy + 1 : gy;
let days = 365 * gy + Math.floor((gy2 + 3) / 4) - Math.floor((gy2 + 99) / 100) + Math.floor((gy2 + 399) / 400) - 80 + gd + g_d_m[gm - 1];
jy += 33 * Math.floor(days / 12053);
days %= 12053;
jy += 4 * Math.floor(days / 1461);
days %= 1461;
if (days > 365) {
jy += Math.floor((days - 1) / 365);
days = (days - 1) % 365;
}
const jm = days < 186 ? 1 + Math.floor(days / 31) : 7 + Math.floor((days - 186) / 30);
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
return `${jy}/${String(jm).padStart(2, '0')}/${String(jd).padStart(2, '0')}`;
};
const formatJalaliDate = (rawDate) => {
if (!rawDate) return '';
const date = rawDate instanceof Date ? rawDate : new Date(rawDate);
if (Number.isNaN(date.getTime())) return '';
return gregorianToJalali(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
};
const utcDayRange = (value) => { const utcDayRange = (value) => {
const date = parseImportDate(value); const date = parseImportDate(value);
if (!date) return null; if (!date) return null;
@@ -91,6 +143,8 @@ const utcDayRange = (value) => {
module.exports = { module.exports = {
toEnglishDigits, toEnglishDigits,
jalaliToGregorian, jalaliToGregorian,
gregorianToJalali,
formatJalaliDate,
parseJalaliDate, parseJalaliDate,
parseImportDate, parseImportDate,
utcDayRange utcDayRange
+4
View File
@@ -14,5 +14,9 @@ describe('jalaliDate', () => {
it('parses ISO and jalali values into UTC midnight dates', () => { it('parses ISO and jalali values into UTC midnight dates', () => {
assert.equal(parseImportDate('2026-07-14').toISOString(), '2026-07-14T00:00:00.000Z'); assert.equal(parseImportDate('2026-07-14').toISOString(), '2026-07-14T00:00:00.000Z');
assert.equal(parseImportDate('1405.5.12').toISOString(), '2026-08-03T00:00:00.000Z'); assert.equal(parseImportDate('1405.5.12').toISOString(), '2026-08-03T00:00:00.000Z');
assert.equal(parseImportDate('1405/05/26').toISOString(), '2026-08-17T00:00:00.000Z');
assert.equal(parseImportDate('1405-05-26').toISOString(), '2026-08-17T00:00:00.000Z');
assert.equal(parseImportDate('۱۴۰۵/۰۵/۲۶').toISOString(), '2026-08-17T00:00:00.000Z');
assert.equal(parseImportDate('2026-08-17T00:00:00.000Z').toISOString(), '2026-08-17T00:00:00.000Z');
}); });
}); });
+11 -1
View File
@@ -24,7 +24,7 @@ const sanitizeNotes = (notes) => {
const rialsToToman = (value) => Math.floor(toNonNegativeNumber(value) / 10); const rialsToToman = (value) => Math.floor(toNonNegativeNumber(value) / 10);
const isCancelledTransaction = (trx = {}) => String(trx.status || '').toLowerCase() === 'cancelled'; const isCancelledTransaction = (trx = {}) => ['cancelled', 'reverted'].includes(String(trx.status || '').toLowerCase());
const isPaidTransaction = (trx = {}) => { const isPaidTransaction = (trx = {}) => {
if (isCancelledTransaction(trx)) return false; if (isCancelledTransaction(trx)) return false;
@@ -44,6 +44,15 @@ const sumPaidTransactions = (transactions = []) => {
}, 0); }, 0);
}; };
const formatPrice = (value) => {
if (value === null || value === undefined || value === '') return '';
const num = typeof value === 'number' ? value : Number(String(value).replace(/,/g, ''));
if (!Number.isNaN(num) && Number.isFinite(num)) {
return num.toLocaleString('en-US');
}
return String(value);
};
const remainingPayable = (payment = {}, transactions = []) => { const remainingPayable = (payment = {}, transactions = []) => {
return Math.max(0, getPayableAmount(payment) - sumPaidTransactions(transactions)); return Math.max(0, getPayableAmount(payment) - sumPaidTransactions(transactions));
}; };
@@ -53,6 +62,7 @@ module.exports = {
getPayableAmount, getPayableAmount,
normalizeDiscount, normalizeDiscount,
sanitizeNotes, sanitizeNotes,
formatPrice,
rialsToToman, rialsToToman,
isPaidTransaction, isPaidTransaction,
isCancelledTransaction, isCancelledTransaction,
+13 -1
View File
@@ -10,7 +10,8 @@ const {
rialsToToman, rialsToToman,
isPaidTransaction, isPaidTransaction,
sumPaidTransactions, sumPaidTransactions,
remainingPayable remainingPayable,
formatPrice
} = require('./paymentAmount'); } = require('./paymentAmount');
describe('payment amount helpers', () => { describe('payment amount helpers', () => {
@@ -40,6 +41,17 @@ describe('payment amount helpers', () => {
}); });
}); });
describe('formatPrice', () => {
it('formats numbers and numeric strings with decimal thousand separators', () => {
assert.equal(formatPrice(8000000), '8,000,000');
assert.equal(formatPrice('8000000'), '8,000,000');
assert.equal(formatPrice(500000), '500,000');
assert.equal(formatPrice(0), '0');
assert.equal(formatPrice(''), '');
assert.equal(formatPrice(null), '');
});
});
describe('rialsToToman', () => { describe('rialsToToman', () => {
it('drops one zero so spreadsheet Rials become website Toman', () => { it('drops one zero so spreadsheet Rials become website Toman', () => {
assert.equal(rialsToToman(110_000_000), 11_000_000); assert.equal(rialsToToman(110_000_000), 11_000_000);
+5 -2
View File
@@ -48,9 +48,12 @@ const resolveSessionDurationHours = (cls = {}) => {
/** /**
* Model A percentage of class revenue. * Model A percentage of class revenue.
* `revenue` is the amount the percentage should be applied to (e.g. actual received revenue). * `revenue` is the amount the percentage should be applied to (e.g. actual received revenue).
* `serviceFeePerPerson` (catering / service expenses per student) is deducted first before calculating the professor share.
*/ */
const calculatePercentageShare = ({ payoutPercentage = 0, revenue = 0 } = {}) => { const calculatePercentageShare = ({ payoutPercentage = 0, revenue = 0, serviceFeePerPerson = 0, studentsCount = 0 } = {}) => {
return (toPercentage(payoutPercentage) / 100) * toNonNegativeNumber(revenue); const totalServiceFee = toNonNegativeNumber(serviceFeePerPerson) * toNonNegativeNumber(studentsCount);
const netRevenue = Math.max(0, toNonNegativeNumber(revenue) - totalServiceFee);
return (toPercentage(payoutPercentage) / 100) * netRevenue;
}; };
/** /**
+15
View File
@@ -52,6 +52,21 @@ describe('calculatePercentageShare (Model A)', () => {
assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: 10_000_000 }), 4_000_000); assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: 10_000_000 }), 4_000_000);
}); });
it('deducts serviceFeePerPerson * studentsCount from revenue before calculating percentage', () => {
// 2 students with 10M tuition (revenue = 20M), serviceFeePerPerson = 400,000, 50% payout
// Net revenue = 20M - (400,000 * 2) = 19,200,000
// Professor share = 19,200,000 * 50% = 9,600,000
assert.equal(
calculatePercentageShare({
payoutPercentage: 50,
revenue: 20_000_000,
serviceFeePerPerson: 400_000,
studentsCount: 2
}),
9_600_000
);
});
it('clamps percentage above 100 and negative revenue to zero', () => { it('clamps percentage above 100 and negative revenue to zero', () => {
assert.equal(calculatePercentageShare({ payoutPercentage: 150, revenue: 1_000_000 }), 1_000_000); assert.equal(calculatePercentageShare({ payoutPercentage: 150, revenue: 1_000_000 }), 1_000_000);
assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: -500 }), 0); assert.equal(calculatePercentageShare({ payoutPercentage: 40, revenue: -500 }), 0);
+111
View File
@@ -0,0 +1,111 @@
'use strict';
/**
* Time-bucketing helpers for financial analytics (daily / weekly / monthly /
* forward-looking forecast windows). Weeks follow the Iranian convention of
* starting on Saturday, ending on Friday.
*/
const startOfDay = (date) => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
};
const endOfDay = (date) => {
const d = new Date(date);
d.setHours(23, 59, 59, 999);
return d;
};
const addDays = (date, days) => {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
};
const addMonths = (date, months) => {
const d = new Date(date);
d.setMonth(d.getMonth() + months);
return d;
};
/** JS getDay(): Sun=0..Sat=6. Iranian week starts Saturday, so Sat -> offset 0, Sun -> 1, ... Fri -> 6. */
const getWeekStart = (date) => {
const d = startOfDay(date);
const offset = (d.getDay() + 1) % 7;
return addDays(d, -offset);
};
/** Builds `days` daily buckets ending on (and including) `endDate`, oldest first. */
const buildDailyBuckets = (days, endDate = new Date()) => {
const end = startOfDay(endDate);
const buckets = [];
for (let i = days - 1; i >= 0; i--) {
const dayStart = addDays(end, -i);
buckets.push({ start: dayStart, end: endOfDay(dayStart), key: dayStart.toISOString().slice(0, 10) });
}
return buckets;
};
/** Builds `weeks` weekly buckets (Sat-Fri) ending on the week containing `endDate`, oldest first. */
const buildWeeklyBuckets = (weeks, endDate = new Date()) => {
const currentWeekStart = getWeekStart(endDate);
const buckets = [];
for (let i = weeks - 1; i >= 0; i--) {
const weekStart = addDays(currentWeekStart, -7 * i);
buckets.push({ start: weekStart, end: endOfDay(addDays(weekStart, 6)), key: weekStart.toISOString().slice(0, 10) });
}
return buckets;
};
/** Builds `weeks` forward-looking weekly buckets (Sat-Fri) starting from the week containing `startDate`. */
const buildForecastWeeklyBuckets = (weeks, startDate = new Date()) => {
const currentWeekStart = getWeekStart(startDate);
const buckets = [];
for (let i = 0; i < weeks; i++) {
const weekStart = addDays(currentWeekStart, 7 * i);
buckets.push({ start: weekStart, end: endOfDay(addDays(weekStart, 6)), key: weekStart.toISOString().slice(0, 10) });
}
return buckets;
};
/** Builds `months` monthly (Gregorian calendar month) buckets ending on the month containing `endDate`, oldest first. */
const buildMonthlyBuckets = (months, endDate = new Date()) => {
const base = new Date(endDate.getFullYear(), endDate.getMonth(), 1, 0, 0, 0, 0);
const buckets = [];
for (let i = months - 1; i >= 0; i--) {
const monthStart = addMonths(base, -i);
const monthEnd = endOfDay(addDays(addMonths(monthStart, 1), -1));
buckets.push({
start: monthStart,
end: monthEnd,
year: monthStart.getFullYear(),
month: monthStart.getMonth() + 1,
key: `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}`
});
}
return buckets;
};
/** Linear search is fine — bucket arrays used here are always small (<=~90 entries). */
const findBucketIndex = (buckets, date) => {
if (!date) return -1;
const d = date instanceof Date ? date : new Date(date);
if (Number.isNaN(d.getTime())) return -1;
const t = d.getTime();
return buckets.findIndex((b) => t >= b.start.getTime() && t <= b.end.getTime());
};
module.exports = {
startOfDay,
endOfDay,
addDays,
addMonths,
getWeekStart,
buildDailyBuckets,
buildWeeklyBuckets,
buildForecastWeeklyBuckets,
buildMonthlyBuckets,
findBucketIndex
};
+2 -1
View File
@@ -22,7 +22,7 @@ const redactSmsParams = (params = []) => (
); );
const sendSingleSms = async (mobile, templateId, 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:', { logger.info('[SMS] About to send notification:', {
mobile, mobile,
@@ -30,6 +30,7 @@ const sendSingleSms = async (mobile, templateId, params = []) => {
params: redactSmsParams(params), params: redactSmsParams(params),
SMS_ENABLED: channelState.envEnabled, SMS_ENABLED: channelState.envEnabled,
dashboardEnabled: channelState.dbEnabled, dashboardEnabled: channelState.dbEnabled,
bypassActive: Boolean(channelState.bypass)
}); });
if (!channelState.enabled) { if (!channelState.enabled) {
+51 -16
View File
@@ -4,7 +4,8 @@
const { sendSingleSms } = require('./sms.base'); const { sendSingleSms } = require('./sms.base');
const { recordAndSend } = require('./notificationRecorder'); const { recordAndSend } = require('./notificationRecorder');
const { getSmsTemplate } = require('../../components/settings/settingService'); const { getSmsTemplate } = require('../../components/settings/settingService');
const { buildSmsParameters } = require('../../components/settings/smsTemplates'); const { buildSmsParameters, renderSmsText } = require('../../components/settings/smsTemplates');
const { isSmsBypassNumber } = require('../../components/settings/messagingFlags');
const User = require('../../components/users/userModel'); const User = require('../../components/users/userModel');
const logger = require('../logger'); const logger = require('../logger');
@@ -33,22 +34,29 @@ const sendTemplateSms = async ({
slotValues = {} slotValues = {}
}) => { }) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver); const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const { templateId, variables, enabled } = await getSmsTemplate(templateKey); 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}.`); logger.info(`[SMS] Template ${templateKey} is disabled. Skipping SMS send to ${receiver}.`);
return { success: true, skipped: true, reason: 'template_disabled' }; return { success: true, skipped: true, reason: 'template_disabled' };
} }
const paramsContext = {
phoneNumber: receiver,
mobile: receiver,
...slotValues
};
const renderedExactBody = renderSmsText(templateText, variables, paramsContext);
const finalBody = (renderedExactBody && renderedExactBody.trim()) ? renderedExactBody.trim() : (body || subject || 'پیامک');
return recordAndSend({ return recordAndSend({
userId: resolvedUserId, userId: resolvedUserId,
channel: 'sms', channel: 'sms',
subject, subject,
body, body: finalBody,
relatedEvent, relatedEvent,
sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, { sendFn: () => sendSingleSms(receiver, templateId, buildSmsParameters(variables, paramsContext))
phoneNumber: receiver,
mobile: receiver,
...slotValues
}))
}); });
}; };
@@ -175,11 +183,13 @@ const sendClassReminderSms = async (receiver, className, time, place = '', userI
}); });
}; };
const { formatPrice } = require('../paymentAmount');
const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => { const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => {
const resolvedUserId = userId || await resolveUserIdByPhone(receiver); const resolvedUserId = userId || await resolveUserIdByPhone(receiver);
const data = typeof payload === 'object' && payload !== null ? payload : { amount: payload }; const data = typeof payload === 'object' && payload !== null ? payload : { amount: payload };
const fullNameLabel = data.fullName || 'کارآموز'; const fullNameLabel = data.fullName || 'کارآموز';
const amountLabel = data.amount != null ? String(data.amount) : ''; const amountLabel = data.amount != null ? formatPrice(data.amount) : '';
const courseLabel = data.course || '-'; const courseLabel = data.course || '-';
return sendTemplateSms({ return sendTemplateSms({
@@ -203,6 +213,7 @@ const sendInvoiceCreatedSms = async (receiver, payload, userId = null) => {
const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) => { const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) => {
const data = payload || {}; const data = payload || {};
const amountLabel = data.amount != null ? formatPrice(data.amount) : '';
return sendTemplateSms({ return sendTemplateSms({
templateKey: 'paymentStatusChanged', templateKey: 'paymentStatusChanged',
receiver, receiver,
@@ -213,7 +224,7 @@ const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) =>
slotValues: { slotValues: {
fullName: data.fullName || 'کارآموز', fullName: data.fullName || 'کارآموز',
status: data.statusLabel || data.status || '-', status: data.statusLabel || data.status || '-',
amount: data.amount != null ? String(data.amount) : '', amount: amountLabel,
invoiceCode: data.invoiceCode || '', invoiceCode: data.invoiceCode || '',
course: data.course || '-' course: data.course || '-'
} }
@@ -222,16 +233,17 @@ const sendPaymentStatusChangedSms = async (receiver, payload, userId = null) =>
const sendPaymentReminderSms = async (receiver, payload, userId = null) => { const sendPaymentReminderSms = async (receiver, payload, userId = null) => {
const data = payload || {}; const data = payload || {};
const amountLabel = data.amount != null ? formatPrice(data.amount) : '-';
return sendTemplateSms({ return sendTemplateSms({
templateKey: 'paymentReminder', templateKey: 'paymentReminder',
receiver, receiver,
userId, userId,
subject: 'یادآوری سررسید پرداخت', subject: 'یادآوری سررسید پرداخت',
body: `یادآوری: مبلغ ${data.amount || '-'} تومان تا ${data.dueDate || '-'} سررسید دارد.`, body: `یادآوری: مبلغ ${amountLabel} تومان تا ${data.dueDate || '-'} سررسید دارد.`,
relatedEvent: 'payment.reminder_due', relatedEvent: 'payment.reminder_due',
slotValues: { slotValues: {
fullName: data.fullName || 'کارآموز', fullName: data.fullName || 'کارآموز',
amount: data.amount != null ? String(data.amount) : '', amount: data.amount != null ? formatPrice(data.amount) : '',
dueDate: data.dueDate || '', dueDate: data.dueDate || '',
invoiceCode: data.invoiceCode || '', invoiceCode: data.invoiceCode || '',
course: data.course || '-' course: data.course || '-'
@@ -241,16 +253,17 @@ const sendPaymentReminderSms = async (receiver, payload, userId = null) => {
const sendTransactionRecordedSms = async (receiver, payload, userId = null) => { const sendTransactionRecordedSms = async (receiver, payload, userId = null) => {
const data = payload || {}; const data = payload || {};
const amountLabel = data.amount != null ? formatPrice(data.amount) : '-';
return sendTemplateSms({ return sendTemplateSms({
templateKey: 'transactionRecorded', templateKey: 'transactionRecorded',
receiver, receiver,
userId, userId,
subject: 'ثبت تراکنش', subject: 'ثبت تراکنش',
body: `تراکنش به مبلغ ${data.amount || '-'} تومان ثبت شد.`, body: `تراکنش به مبلغ ${amountLabel} تومان ثبت شد.`,
relatedEvent: 'payment.transaction_added', relatedEvent: 'payment.transaction_added',
slotValues: { slotValues: {
fullName: data.fullName || 'کارآموز', fullName: data.fullName || 'کارآموز',
amount: data.amount != null ? String(data.amount) : '', amount: data.amount != null ? formatPrice(data.amount) : '',
invoiceCode: data.invoiceCode || '', invoiceCode: data.invoiceCode || '',
transactionCode: data.transactionCode || '', transactionCode: data.transactionCode || '',
receiptNumber: data.receiptNumber || '' receiptNumber: data.receiptNumber || ''
@@ -370,6 +383,27 @@ const sendCertificateIssuedSms = async (receiver, payload, userId = null) => {
}); });
}; };
const sendClassPlanProfessorSms = async (receiver, payload, userId = null) => {
const data = payload || {};
return sendTemplateSms({
templateKey: 'classPlanProfessor',
receiver,
userId,
subject: 'برنامه کلاس به استاد',
body: `استاد ${data.professorName || ''}، برنامه کلاس ${data.className || ''}: ${data.classDays || ''}، ${data.classTimes || ''} از ${data.classStartDate || ''} الی ${data.classEndDate || ''}`,
relatedEvent: 'class.plan_to_professor',
slotValues: {
professorName: data.professorName || 'استاد',
className: data.className || '-',
classDays: data.classDays || '',
classTimes: data.classTimes || '',
classStartDate: data.classStartDate || '',
classEndDate: data.classEndDate || '',
phoneNumber: receiver
}
});
};
module.exports = { module.exports = {
sendAccountCreatedSms, sendAccountCreatedSms,
sendPasswordResetSms, sendPasswordResetSms,
@@ -384,5 +418,6 @@ module.exports = {
sendClassRequestApprovedSms, sendClassRequestApprovedSms,
sendClassRequestRejectedSms, sendClassRequestRejectedSms,
sendPendingRegistrationSms, sendPendingRegistrationSms,
sendCertificateIssuedSms sendCertificateIssuedSms,
sendClassPlanProfessorSms
}; };