Initial commit: teaching institution management API.

Express/MongoDB backend with JWT auth, RBAC, S3 uploads, notifications, and scheduled jobs.
This commit is contained in:
2026-08-09 04:18:08 +02:00
commit f04c797be6
107 changed files with 9190 additions and 0 deletions
@@ -0,0 +1,16 @@
// /components/activityLogs/activityLogController.js
'use strict';
const catchAsync = require('../../utils/catchAsync');
const activityLogService = require('./activityLogService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await activityLogService.getAllActivityLogs(req.query);
return listResponse(res, 200, data, meta);
});
exports.getActions = catchAsync(async (req, res) => {
const actions = activityLogService.getActivityActions();
return successResponse(res, 200, 'Activity actions retrieved successfully', actions);
});
@@ -0,0 +1,87 @@
// /components/activityLogs/activityLogModel.js
'use strict';
const mongoose = require('mongoose');
const ACTIVITY_ACTIONS = [
'create',
'update',
'delete',
'login',
'logout',
'enroll',
'attendance',
'upload',
'retry',
'other'
];
const activityLogSchema = new mongoose.Schema(
{
actor: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
default: null,
index: true
},
actorUsername: {
type: String,
default: null,
index: true
},
actorName: {
type: String,
default: null
},
action: {
type: String,
enum: ACTIVITY_ACTIONS,
required: true,
index: true
},
resource: {
type: String,
required: true,
index: true
},
resourceId: {
type: String,
default: null
},
method: {
type: String,
required: true
},
path: {
type: String,
required: true
},
statusCode: {
type: Number,
default: null
},
ip: {
type: String,
default: null
},
userAgent: {
type: String,
default: null
},
description: {
type: String,
default: ''
},
metadata: {
type: mongoose.Schema.Types.Mixed,
default: {}
}
},
{ timestamps: true }
);
activityLogSchema.index({ createdAt: -1 });
activityLogSchema.index({ action: 1, createdAt: -1 });
module.exports = mongoose.model('ActivityLog', activityLogSchema);
module.exports.ACTIVITY_ACTIONS = ACTIVITY_ACTIONS;
@@ -0,0 +1,26 @@
// /components/activityLogs/activityLogRoutes.js
'use strict';
const express = require('express');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const activityLogController = require('./activityLogController');
const router = express.Router();
router.get(
'/admin/get-all',
authMiddleware,
perm.requires(PERMISSIONS.LOGS_READ),
activityLogController.getAll
);
router.get(
'/admin/actions',
authMiddleware,
perm.requires(PERMISSIONS.LOGS_READ),
activityLogController.getActions
);
module.exports = router;
@@ -0,0 +1,146 @@
// /components/activityLogs/activityLogService.js
'use strict';
const ActivityLog = require('./activityLogModel');
const { ACTIVITY_ACTIONS } = require('./activityLogModel');
const { parsePaginationAndSort, calculateMeta } = require('../../utils/pagination');
const SENSITIVE_KEYS = new Set([
'password',
'passwordHash',
'refreshToken',
'accessToken',
'token',
'secret',
'smtp_pass',
'SMTP_PASS'
]);
const sanitizeValue = (value, depth = 0) => {
if (value == null) return value;
if (depth > 3) return '[truncated]';
if (Array.isArray(value)) {
return value.slice(0, 20).map((item) => sanitizeValue(item, depth + 1));
}
if (typeof value === 'object') {
const cleaned = {};
Object.keys(value).slice(0, 30).forEach((key) => {
if (SENSITIVE_KEYS.has(key)) {
cleaned[key] = '[redacted]';
} else {
cleaned[key] = sanitizeValue(value[key], depth + 1);
}
});
return cleaned;
}
if (typeof value === 'string' && value.length > 300) {
return `${value.slice(0, 300)}`;
}
return value;
};
const buildDescription = ({ action, resource, actorName, actorUsername, method, path }) => {
const ACTION_LABELS = {
create: 'ایجاد',
update: 'ویرایش',
delete: 'حذف',
login: 'ورود',
logout: 'خروج',
enroll: 'ثبت‌نام',
attendance: 'حضور و غیاب',
upload: 'آپلود',
retry: 'تلاش مجدد',
other: 'عملیات'
};
const RESOURCE_LABELS = {
auth: 'احراز هویت',
users: 'کاربران',
professors: 'اساتید',
courses: 'دوره‌ها',
classes: 'کلاس‌ها',
sessions: 'جلسات',
payments: 'پرداخت‌ها',
roles: 'نقش‌ها',
notifications: 'اطلاعیه‌ها',
certificates: 'گواهینامه‌ها',
files: 'فایل‌ها',
dashboard: 'داشبورد',
'contact-inquiries': 'درخواست‌های تماس',
'activity-logs': 'گزارش فعالیت‌ها'
};
const who = actorName || actorUsername || 'سیستم';
const actionLabel = ACTION_LABELS[action] || action || 'عملیات';
const resourceLabel = RESOURCE_LABELS[resource] || resource || 'منبع';
return `${who}${actionLabel} روی ${resourceLabel} (${method} ${path})`;
};
const createActivityLog = async (payload) => {
const doc = {
...payload,
metadata: sanitizeValue(payload.metadata || {}),
description:
payload.description ||
buildDescription(payload)
};
return ActivityLog.create(doc);
};
const getAllActivityLogs = async (queryParams = {}) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = {};
if (queryParams.action) {
filter.action = queryParams.action;
}
if (queryParams.resource) {
filter.resource = queryParams.resource;
}
if (queryParams.actor) {
filter.actor = queryParams.actor;
}
if (queryParams.q) {
const searchRegex = new RegExp(queryParams.q, 'i');
filter.$or = [
{ description: searchRegex },
{ actorUsername: searchRegex },
{ actorName: searchRegex },
{ path: searchRegex },
{ resource: searchRegex },
{ resourceId: searchRegex }
];
}
const [logs, totalCount] = await Promise.all([
ActivityLog.find(filter)
.populate('actor', 'name surname username')
.sort(sort)
.skip(skip)
.limit(limit)
.lean(),
ActivityLog.countDocuments(filter)
]);
return {
data: logs,
meta: calculateMeta(totalCount, page, limit)
};
};
const getActivityActions = () => ACTIVITY_ACTIONS;
module.exports = {
createActivityLog,
getAllActivityLogs,
getActivityActions,
sanitizeValue,
buildDescription
};
+24
View File
@@ -0,0 +1,24 @@
// /components/auth/authController.js
const catchAsync = require('../../utils/catchAsync');
const authService = require('./authService');
const { successResponse } = require('../../utils/apiResponse');
exports.login = catchAsync(async (req, res, next) => {
const { username, password } = req.body;
const result = await authService.login(username, password);
return successResponse(res, 200, 'Login successful', result);
});
exports.refresh = catchAsync(async (req, res, next) => {
const { refreshToken } = req.body;
const result = await authService.refreshToken(refreshToken);
return successResponse(res, 200, 'Tokens refreshed successfully', result);
});
exports.logout = catchAsync(async (req, res, next) => {
const { refreshToken } = req.body;
const userId = req.user?._id;
await authService.logout(userId, refreshToken);
return successResponse(res, 200, 'Logout successful');
});
+14
View File
@@ -0,0 +1,14 @@
// /components/auth/authRoutes.js
const express = require('express');
const authController = require('./authController');
const { validateLogin, validateRefresh } = require('./authValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const router = express.Router();
router.post('/login', validateLogin, authController.login);
router.post('/refresh', validateRefresh, authController.refresh);
router.post('/logout', authMiddleware, authController.logout);
module.exports = router;
+110
View File
@@ -0,0 +1,110 @@
// /components/auth/authService.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const User = require('../users/userModel');
const config = require('../../config/config');
const AppError = require('../../utils/AppError');
const generateTokens = (user) => {
const payload = {
id: user._id,
username: user.username,
role: user.role?._id || user.role
};
const accessToken = jwt.sign(payload, config.JWT_ACCESS_SECRET, {
expiresIn: config.JWT_ACCESS_EXPIRES_IN
});
const refreshToken = jwt.sign({ id: user._id }, config.JWT_REFRESH_SECRET, {
expiresIn: config.JWT_REFRESH_EXPIRES_IN
});
return { accessToken, refreshToken };
};
const login = async (username, password) => {
const user = await User.findOne({ username }).populate('role');
if (!user || !user.isActive) {
throw new AppError('INVALID_CREDENTIALS');
}
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
throw new AppError('INVALID_CREDENTIALS');
}
const { accessToken, refreshToken: refreshTokenString } = generateTokens(user);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
user.refreshTokens.push({ token: refreshTokenString, expiresAt });
await user.save();
const userObject = user.toObject();
delete userObject.passwordHash;
delete userObject.refreshTokens;
return {
user: userObject,
accessToken,
refreshToken: refreshTokenString
};
};
const refreshToken = async (refreshTokenString) => {
let decoded;
try {
decoded = jwt.verify(refreshTokenString, config.JWT_REFRESH_SECRET);
} catch (err) {
throw new AppError('INVALID_REFRESH_TOKEN');
}
const user = await User.findById(decoded.id).populate('role');
if (!user || !user.isActive) {
throw new AppError('INVALID_REFRESH_TOKEN');
}
const tokenIndex = user.refreshTokens.findIndex(rt => rt.token === refreshTokenString);
if (tokenIndex === -1) {
throw new AppError('INVALID_REFRESH_TOKEN');
}
if (new Date() > new Date(user.refreshTokens[tokenIndex].expiresAt)) {
user.refreshTokens.splice(tokenIndex, 1);
await user.save();
throw new AppError('TOKEN_EXPIRED');
}
user.refreshTokens.splice(tokenIndex, 1);
const { accessToken, refreshToken: newRefreshToken } = generateTokens(user);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
user.refreshTokens.push({ token: newRefreshToken, expiresAt });
await user.save();
return {
accessToken,
refreshToken: newRefreshToken
};
};
const logout = async (userId, refreshTokenString) => {
const user = await User.findById(userId);
if (user) {
user.refreshTokens = user.refreshTokens.filter(rt => rt.token !== refreshTokenString);
await user.save();
}
return true;
};
module.exports = {
login,
refreshToken,
logout
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
@@ -0,0 +1,45 @@
// /components/certificates/certificateController.js
const catchAsync = require('../../utils/catchAsync');
const certificateService = require('./certificateService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res, next) => {
const certificate = await certificateService.createCertificate(req.body);
return successResponse(res, 201, 'Certificate created successfully', certificate);
});
exports.getOne = catchAsync(async (req, res, next) => {
const certificate = await certificateService.getCertificateById(req.params.id);
return successResponse(res, 200, 'Certificate retrieved successfully', certificate);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await certificateService.getAllCertificates(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const certificate = await certificateService.updateCertificate(req.params.id, req.body);
return successResponse(res, 200, 'Certificate updated successfully', certificate);
});
exports.delete = catchAsync(async (req, res, next) => {
await certificateService.deleteCertificate(req.params.id);
return successResponse(res, 200, 'Certificate deleted successfully');
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await certificateService.searchCertificates(req.query);
return listResponse(res, 200, data, meta);
});
exports.upload = catchAsync(async (req, res, next) => {
const certificate = await certificateService.uploadUserCertificate(req.user._id, req.body);
return successResponse(res, 201, 'Certificate uploaded and committed successfully', certificate);
});
exports.getMyCertificates = catchAsync(async (req, res, next) => {
const { data, meta } = await certificateService.getMyCertificates(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
@@ -0,0 +1,47 @@
// /components/certificates/certificateModel.js
const mongoose = require('mongoose');
const certificateSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course',
index: true
},
title: {
type: String,
required: true,
trim: true
},
issuer: {
type: String,
trim: true
},
fileKey: {
type: String,
required: true,
trim: true
},
originalName: {
type: String,
trim: true
},
issuedAt: {
type: Date,
default: Date.now
},
isOfficial: {
type: Boolean,
default: false
}
}, {
timestamps: true
});
module.exports = mongoose.model('Certificate', certificateSchema);
@@ -0,0 +1,30 @@
// /components/certificates/certificateRoutes.js
const express = require('express');
const certificateController = require('./certificateController');
const {
validateCreateCertificate,
validateUpdateCertificate,
validateUploadCertificate
} = require('./certificateValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
// User Scope
router.get('/user/my-certificates', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getMyCertificates);
router.post('/user/upload', perm.requires(PERMISSIONS.FILES_UPLOAD), validateUploadCertificate, certificateController.upload);
// Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.CERTIFICATES_CREATE), validateCreateCertificate, certificateController.create);
router.get('/admin/get-all', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.CERTIFICATES_SEARCH), certificateController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.CERTIFICATES_UPDATE), validateUpdateCertificate, certificateController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.CERTIFICATES_DELETE), certificateController.delete);
module.exports = router;
@@ -0,0 +1,181 @@
// /components/certificates/certificateService.js
const path = require('path');
const Certificate = require('./certificateModel');
const User = require('../users/userModel');
const AppError = require('../../utils/AppError');
const { commitTempFile, generatePresignedUrl, deleteFromBucket } = require('../../utils/s3Client');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const createCertificate = async (data) => {
const user = await User.findById(data.user);
if (!user) throw new AppError('USER_NOT_FOUND');
// Move file from Temp bucket to Permanent Storage bucket
const targetKey = `certificates/cert-${Date.now()}-${path.basename(data.tempFileName)}`;
const { fileKey } = await commitTempFile(data.tempFileName, targetKey);
const certificate = await Certificate.create({
user: data.user,
course: data.course || null,
title: data.title,
issuer: data.issuer || '',
fileKey,
originalName: data.originalName || path.basename(data.tempFileName),
isOfficial: data.isOfficial || false
});
user.certificates.push(certificate._id);
await user.save();
eventEmitter.emit(EVENT_NAMES.CERTIFICATE_ISSUED, { certificateId: certificate._id, userId: user._id });
const resultObj = certificate.toObject();
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
return resultObj;
};
const getCertificateById = async (id) => {
const certificate = await Certificate.findById(id)
.populate('user', 'name surname username nationalIdCode')
.populate('course', 'title type');
if (!certificate) {
throw new AppError('CERTIFICATE_NOT_FOUND');
}
const resultObj = certificate.toObject();
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
return resultObj;
};
const getAllCertificates = async (queryParams) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['title', 'issuer']);
const [certificates, totalCount] = await Promise.all([
Certificate.find(filter)
.populate('user', 'name surname username')
.populate('course', 'title')
.sort(sort)
.skip(skip)
.limit(limit),
Certificate.countDocuments(filter)
]);
const listWithUrls = await Promise.all(
certificates.map(async (cert) => {
const item = cert.toObject();
item.presignedUrl = await generatePresignedUrl(cert.fileKey);
return item;
})
);
const meta = calculateMeta(totalCount, page, limit);
return { data: listWithUrls, meta };
};
const updateCertificate = async (id, updateData) => {
const certificate = await Certificate.findById(id);
if (!certificate) {
throw new AppError('CERTIFICATE_NOT_FOUND');
}
if (updateData.tempFileName) {
const targetKey = `certificates/cert-${Date.now()}-${path.basename(updateData.tempFileName)}`;
const { fileKey } = await commitTempFile(updateData.tempFileName, targetKey);
// Delete old file from storage bucket
await deleteFromBucket(certificate.fileKey);
certificate.fileKey = fileKey;
}
if (updateData.title) certificate.title = updateData.title;
if (updateData.issuer !== undefined) certificate.issuer = updateData.issuer;
if (updateData.isOfficial !== undefined) certificate.isOfficial = updateData.isOfficial;
await certificate.save();
const resultObj = certificate.toObject();
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
return resultObj;
};
const deleteCertificate = async (id) => {
const certificate = await Certificate.findById(id);
if (!certificate) {
throw new AppError('CERTIFICATE_NOT_FOUND');
}
await deleteFromBucket(certificate.fileKey);
await User.findByIdAndUpdate(certificate.user, { $pull: { certificates: certificate._id } });
await Certificate.findByIdAndDelete(id);
return null;
};
const searchCertificates = async (queryParams) => {
return getAllCertificates(queryParams);
};
const uploadUserCertificate = async (userId, data) => {
const user = await User.findById(userId);
if (!user) throw new AppError('USER_NOT_FOUND');
const targetKey = `certificates/cert-${Date.now()}-${path.basename(data.tempFileName)}`;
const { fileKey } = await commitTempFile(data.tempFileName, targetKey);
const certificate = await Certificate.create({
user: userId,
course: data.course || null,
title: data.title,
issuer: data.issuer || 'Self-Uploaded',
fileKey,
originalName: data.originalName || path.basename(data.tempFileName),
isOfficial: false
});
user.certificates.push(certificate._id);
await user.save();
eventEmitter.emit(EVENT_NAMES.CERTIFICATE_UPLOADED, { certificateId: certificate._id, userId });
const resultObj = certificate.toObject();
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
return resultObj;
};
const getMyCertificates = async (userId, queryParams) => {
const filter = { user: userId, ...buildFilterQuery(queryParams) };
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const [certificates, totalCount] = await Promise.all([
Certificate.find(filter)
.populate('course', 'title type')
.sort(sort)
.skip(skip)
.limit(limit),
Certificate.countDocuments(filter)
]);
const listWithUrls = await Promise.all(
certificates.map(async (cert) => {
const item = cert.toObject();
item.presignedUrl = await generatePresignedUrl(cert.fileKey);
return item;
})
);
const meta = calculateMeta(totalCount, page, limit);
return { data: listWithUrls, meta };
};
module.exports = {
createCertificate,
getCertificateById,
getAllCertificates,
updateCertificate,
deleteCertificate,
searchCertificates,
uploadUserCertificate,
getMyCertificates
};
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+41
View File
@@ -0,0 +1,41 @@
// /components/classes/classController.js
'use strict';
const catchAsync = require('../../utils/catchAsync');
const classService = require('./classService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await classService.getAll(req.query);
return listResponse(res, 200, data, meta);
});
exports.getOne = catchAsync(async (req, res) => {
const cls = await classService.getOne(req.params.id);
return successResponse(res, 200, 'Class retrieved successfully', cls);
});
exports.create = catchAsync(async (req, res) => {
const cls = await classService.create(req.body);
return successResponse(res, 201, 'Class created successfully', cls);
});
exports.update = catchAsync(async (req, res) => {
const cls = await classService.update(req.params.id, req.body);
return successResponse(res, 200, 'Class updated successfully', cls);
});
exports.delete = catchAsync(async (req, res) => {
await classService.remove(req.params.id);
return successResponse(res, 200, 'Class deleted successfully');
});
exports.registerUsers = catchAsync(async (req, res) => {
const cls = await classService.registerUsers(req.params.id, req.body.userIds || []);
return successResponse(res, 200, 'Users registered in class successfully', cls);
});
exports.getMyClasses = catchAsync(async (req, res) => {
const { data, meta } = await classService.getMyClasses(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
+47
View File
@@ -0,0 +1,47 @@
// /components/classes/classModel.js
'use strict';
const mongoose = require('mongoose');
const classSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true
},
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course',
required: true
},
professor: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Professor'
},
students: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
capacity: {
type: Number,
default: 30
},
tuitionFee: {
type: Number,
default: 0
},
startDate: {
type: Date
},
endDate: {
type: Date
},
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true
});
module.exports = mongoose.model('Class', classSchema);
+25
View File
@@ -0,0 +1,25 @@
// /components/classes/classRoutes.js
'use strict';
const express = require('express');
const classController = require('./classController');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
// User Scope
router.get('/user/my-classes', perm.requires(PERMISSIONS.CLASSES_READ), classController.getMyClasses);
// Admin Scope
router.get('/admin/get-all', perm.requires(PERMISSIONS.CLASSES_READ), classController.getAll);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.CLASSES_READ), classController.getOne);
router.post('/admin/create', perm.requires(PERMISSIONS.CLASSES_CREATE), classController.create);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.CLASSES_UPDATE), classController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.CLASSES_DELETE), classController.delete);
router.post('/admin/:id/register-users', perm.requires(PERMISSIONS.CLASSES_REGISTER_USERS), classController.registerUsers);
module.exports = router;
+108
View File
@@ -0,0 +1,108 @@
// /components/classes/classService.js
'use strict';
const Class = require('./classModel');
const User = require('../users/userModel');
const AppError = require('../../utils/AppError');
const { calculateMeta } = require('../../utils/pagination');
const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages');
const logger = require('../../utils/logger');
const getAll = async (query) => {
const page = parseInt(query.page) || 1;
const limit = Math.min(parseInt(query.limit) || 20, 200);
const skip = (page - 1) * limit;
const filter = {};
if (query.courseId) filter.course = query.courseId;
if (query.isActive !== undefined) filter.isActive = query.isActive === 'true';
const [items, total] = await Promise.all([
Class.find(filter)
.populate({ path: 'course', select: 'title type price' })
.populate({ path: 'professor', select: 'name surname' })
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
Class.countDocuments(filter)
]);
return { data: items, meta: calculateMeta(total, page, limit) };
};
const getOne = async (id) => {
const cls = await Class.findById(id)
.populate({ path: 'course', select: 'title type price' })
.populate({ path: 'professor', select: 'name surname phoneNumber' })
.populate({ path: 'students', select: 'name surname phoneNumber' })
.lean();
if (!cls) throw new AppError('CLASS_NOT_FOUND');
return cls;
};
const create = async (body) => {
const cls = await Class.create(body);
return getOne(cls._id);
};
const update = async (id, body) => {
const cls = await Class.findByIdAndUpdate(id, body, { new: true, runValidators: true })
.populate({ path: 'course', select: 'title' })
.lean();
if (!cls) throw new AppError('CLASS_NOT_FOUND');
return cls;
};
const remove = async (id) => {
const cls = await Class.findByIdAndDelete(id);
if (!cls) throw new AppError('CLASS_NOT_FOUND');
};
const registerUsers = async (classId, userIds) => {
const cls = await Class.findById(classId).populate({ path: 'course', select: 'title' });
if (!cls) throw new AppError('CLASS_NOT_FOUND');
const toAdd = (userIds || []).filter(
(id) => !cls.students.map((s) => s.toString()).includes(id.toString())
);
if (toAdd.length === 0) {
return getOne(classId);
}
cls.students.push(...toAdd);
await cls.save();
const classLabel = cls.name || cls.course?.title || 'کلاس';
const users = await User.find({ _id: { $in: toAdd } }).select('phoneNumber').lean();
await Promise.all(
users.map(async (user) => {
if (!user.phoneNumber) return;
try {
await sendClassRegisteredSms(user.phoneNumber, classLabel, user._id);
} catch (err) {
logger.error(`[registerUsers] SMS failed for ${user.phoneNumber}: ${err.message}`);
}
})
);
return getOne(classId);
};
const getMyClasses = async (userId, query = {}) => {
const page = parseInt(query.page) || 1;
const limit = Math.min(parseInt(query.limit) || 20, 200);
const skip = (page - 1) * limit;
const filter = { students: userId };
if (query.isActive !== undefined) filter.isActive = query.isActive === 'true';
const [items, total] = await Promise.all([
Class.find(filter)
.populate({ path: 'course', select: 'title type price description' })
.populate({ path: 'professor', select: 'name surname' })
.skip(skip).limit(limit).sort({ startDate: -1, createdAt: -1 }).lean(),
Class.countDocuments(filter)
]);
return { data: items, meta: calculateMeta(total, page, limit) };
};
module.exports = { getAll, getOne, create, update, remove, registerUsers, getMyClasses };
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
@@ -0,0 +1,25 @@
// /components/contactInquiries/contactInquiryController.js
const catchAsync = require('../../utils/catchAsync');
const contactInquiryService = require('./contactInquiryService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res) => {
const inquiry = await contactInquiryService.createInquiry(req.body);
return successResponse(res, 201, 'Contact inquiry submitted successfully', inquiry);
});
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await contactInquiryService.getAllInquiries(req.query);
return listResponse(res, 200, data, meta);
});
exports.getOne = catchAsync(async (req, res) => {
const inquiry = await contactInquiryService.getInquiryById(req.params.id);
return successResponse(res, 200, 'Contact inquiry retrieved successfully', inquiry);
});
exports.update = catchAsync(async (req, res) => {
const inquiry = await contactInquiryService.updateInquiry(req.params.id, req.body);
return successResponse(res, 200, 'Contact inquiry updated successfully', inquiry);
});
@@ -0,0 +1,81 @@
// /components/contactInquiries/contactInquiryModel.js
const mongoose = require('mongoose');
const CONTACT_METHODS = [
'WhatsApp',
'Telegram',
'Soroush',
'Bale',
'Eitaa',
'SMS',
'Call'
];
const CONTACT_STATUSES = [
'new',
'seen',
'call_later',
'contacted',
'closed'
];
const contactInquirySchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true
},
surname: {
type: String,
required: true,
trim: true
},
nationalIdCode: {
type: String,
trim: true,
index: true
},
phoneNumber: {
type: String,
trim: true,
index: true
},
email: {
type: String,
trim: true,
lowercase: true
},
message: {
type: String,
trim: true,
maxlength: 2000
},
preferredContactMethods: [{
type: String,
enum: CONTACT_METHODS
}],
courses: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Course'
}],
status: {
type: String,
enum: CONTACT_STATUSES,
default: 'new',
index: true
},
notes: {
type: String,
trim: true,
maxlength: 5000,
default: ''
}
}, {
timestamps: true
});
contactInquirySchema.statics.CONTACT_METHODS = CONTACT_METHODS;
contactInquirySchema.statics.CONTACT_STATUSES = CONTACT_STATUSES;
module.exports = mongoose.model('ContactInquiry', contactInquirySchema);
@@ -0,0 +1,35 @@
// /components/contactInquiries/contactInquiryRoutes.js
const express = require('express');
const contactInquiryController = require('./contactInquiryController');
const { validateCreateInquiry } = require('./contactInquiryValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
// Public — website contact form
router.post('/public/create', validateCreateInquiry, contactInquiryController.create);
// Admin
router.get(
'/admin/get-all',
authMiddleware,
perm.requires(PERMISSIONS.CONTACT_INQUIRIES_READ),
contactInquiryController.getAll
);
router.get(
'/admin/get-one/:id',
authMiddleware,
perm.requires(PERMISSIONS.CONTACT_INQUIRIES_READ),
contactInquiryController.getOne
);
router.put(
'/admin/update/:id',
authMiddleware,
perm.requires(PERMISSIONS.CONTACT_INQUIRIES_UPDATE),
contactInquiryController.update
);
module.exports = router;
@@ -0,0 +1,122 @@
// /components/contactInquiries/contactInquiryService.js
const ContactInquiry = require('./contactInquiryModel');
const Course = require('../courses/courseModel');
const AppError = require('../../utils/AppError');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const normalizePhone = (value) => {
if (!value) return undefined;
return String(value).replace(/[\s\-()]/g, '').trim();
};
const createInquiry = async (data) => {
const preferredContactMethods = Array.isArray(data.preferredContactMethods)
? [...new Set(data.preferredContactMethods)]
: [];
if (!preferredContactMethods.length) {
throw new AppError('VALIDATION_FAILED', {
preferredContactMethods: 'At least one preferred contact method is required'
}, 'حداقل یک روش تماس را انتخاب کنید.');
}
const phoneNumber = normalizePhone(data.phoneNumber || data.phone);
const email = data.email ? String(data.email).trim().toLowerCase() : undefined;
if (!phoneNumber && !email) {
throw new AppError('VALIDATION_FAILED', {
contact: 'Phone number or email is required'
}, 'شماره تلفن یا ایمیل الزامی است.');
}
const courseIds = Array.isArray(data.courses)
? [...new Set(data.courses.filter(Boolean).map(String))]
: [];
if (courseIds.length) {
const foundCount = await Course.countDocuments({
_id: { $in: courseIds },
showOnFrontend: { $ne: false }
});
if (foundCount !== courseIds.length) {
throw new AppError('COURSE_NOT_FOUND', null, 'یکی از دوره‌های انتخاب‌شده یافت نشد.');
}
}
const inquiry = await ContactInquiry.create({
name: data.name || data.firstName,
surname: data.surname || data.lastName,
nationalIdCode: data.nationalIdCode || data.nationalId || undefined,
phoneNumber,
email,
message: data.message || undefined,
preferredContactMethods,
courses: courseIds
});
return inquiry.populate('courses', 'title type price');
};
const getAllInquiries = async (queryParams) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams, 'createdAt', 'desc');
const filter = buildFilterQuery(
queryParams,
['name', 'surname', 'nationalIdCode', 'phoneNumber', 'email', 'notes', 'message'],
['page', 'limit', 'sortBy', 'sortOrder', 'q', 'lang', 'course', 'courseId', 'courses']
);
const courseFilter = queryParams.course || queryParams.courseId || queryParams.courses;
if (courseFilter) {
const ids = String(courseFilter).split(',').map((id) => id.trim()).filter(Boolean);
if (ids.length === 1) filter.courses = ids[0];
else if (ids.length > 1) filter.courses = { $in: ids };
}
const [data, totalCount] = await Promise.all([
ContactInquiry.find(filter)
.populate('courses', 'title type price')
.sort(sort)
.skip(skip)
.limit(limit),
ContactInquiry.countDocuments(filter)
]);
return { data, meta: calculateMeta(totalCount, page, limit) };
};
const getInquiryById = async (id) => {
const inquiry = await ContactInquiry.findById(id).populate('courses', 'title type price');
if (!inquiry) {
throw new AppError('NOT_FOUND', null, 'درخواست تماس یافت نشد.');
}
return inquiry;
};
const updateInquiry = async (id, updateData) => {
const inquiry = await ContactInquiry.findById(id);
if (!inquiry) {
throw new AppError('NOT_FOUND', null, 'درخواست تماس یافت نشد.');
}
if (updateData.status !== undefined) {
if (!ContactInquiry.CONTACT_STATUSES.includes(updateData.status)) {
throw new AppError('VALIDATION_FAILED', { status: 'Invalid status' }, 'وضعیت نامعتبر است.');
}
inquiry.status = updateData.status;
}
if (updateData.notes !== undefined) {
inquiry.notes = String(updateData.notes).slice(0, 5000);
}
await inquiry.save();
return getInquiryById(id);
};
module.exports = {
createInquiry,
getAllInquiries,
getInquiryById,
updateInquiry
};
@@ -0,0 +1,91 @@
// /components/contactInquiries/contactInquiryValidator.js
const AppError = require('../../utils/AppError');
const ContactInquiry = require('./contactInquiryModel');
const IRAN_MOBILE = /^(0?9\d{9}|\+989\d{9}|00989\d{9})$/;
const EMAIL_RE = /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/;
const isValidNationalId = (code) => {
if (!/^\d{10}$/.test(code)) return false;
if (/^(\d)\1{9}$/.test(code)) return false;
const check = Number(code[9]);
const sum = code
.split('')
.slice(0, 9)
.reduce((acc, digit, index) => acc + Number(digit) * (10 - index), 0);
const remainder = sum % 11;
return (remainder < 2 && check === remainder) || (remainder >= 2 && check === 11 - remainder);
};
const validateCreateInquiry = (req, res, next) => {
const body = req.body || {};
const details = {};
const name = (body.name || body.firstName || '').trim();
const surname = (body.surname || body.lastName || '').trim();
const nationalIdCode = (body.nationalIdCode || body.nationalId || '').trim();
const phoneNumber = String(body.phoneNumber || body.phone || '').replace(/[\s\-()]/g, '').trim();
const email = (body.email || '').trim();
const message = (body.message || '').trim();
const preferredContactMethods = Array.isArray(body.preferredContactMethods)
? body.preferredContactMethods
: [];
const courses = Array.isArray(body.courses) ? body.courses : [];
if (!name) details.name = 'نام الزامی است';
if (!surname) details.surname = 'نام خانوادگی الزامی است';
if (nationalIdCode && !isValidNationalId(nationalIdCode)) {
details.nationalIdCode = 'کد ملی معتبر نیست';
}
if (phoneNumber && !IRAN_MOBILE.test(phoneNumber)) {
details.phoneNumber = 'شماره موبایل معتبر نیست';
}
if (email && !EMAIL_RE.test(email)) {
details.email = 'ایمیل معتبر نیست';
}
if (!phoneNumber && !email) {
details.contact = 'شماره تلفن یا ایمیل الزامی است';
}
if (message.length > 2000) {
details.message = 'پیام نباید بیش از ۲۰۰۰ کاراکتر باشد';
}
const allowed = ContactInquiry.CONTACT_METHODS;
const invalidMethods = preferredContactMethods.filter((m) => !allowed.includes(m));
if (!preferredContactMethods.length) {
details.preferredContactMethods = 'حداقل یک روش تماس را انتخاب کنید';
} else if (invalidMethods.length) {
details.preferredContactMethods = 'روش تماس نامعتبر است';
}
if (courses.some((id) => typeof id !== 'string' && typeof id !== 'number')) {
details.courses = 'شناسه دوره نامعتبر است';
}
if (Object.keys(details).length) {
return next(new AppError('VALIDATION_FAILED', details));
}
req.body = {
name,
surname,
nationalIdCode: nationalIdCode || undefined,
phoneNumber: phoneNumber || undefined,
email: email || undefined,
message: message || undefined,
preferredContactMethods,
courses
};
return next();
};
module.exports = {
validateCreateInquiry
};
+43
View File
@@ -0,0 +1,43 @@
// /components/courses/courseController.js
const catchAsync = require('../../utils/catchAsync');
const courseService = require('./courseService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
const isPublicRequest = (req) => String(req.originalUrl || req.path || '').includes('/user/');
exports.create = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const course = await courseService.createCourse(req.body, actorId);
return successResponse(res, 201, 'Course created successfully', course);
});
exports.getOne = catchAsync(async (req, res) => {
const course = await courseService.getCourseById(req.params.id, {
publicOnly: isPublicRequest(req)
});
return successResponse(res, 200, 'Course retrieved successfully', course);
});
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await courseService.getAllCourses(req.query, {
publicOnly: isPublicRequest(req)
});
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const course = await courseService.updateCourse(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Course updated successfully', course);
});
exports.delete = catchAsync(async (req, res) => {
await courseService.deleteCourse(req.params.id);
return successResponse(res, 200, 'Course deleted successfully');
});
exports.search = catchAsync(async (req, res) => {
const { data, meta } = await courseService.searchCourses(req.query);
return listResponse(res, 200, data, meta);
});
+95
View File
@@ -0,0 +1,95 @@
// /components/courses/courseModel.js
const mongoose = require('mongoose');
const discountSchema = new mongoose.Schema({
percent: { type: Number, required: true, min: 0, max: 100 },
validFrom: { type: Date, required: true },
validTo: { type: Date, required: true },
isActive: { type: Boolean, default: true }
});
const offerSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true },
description: { type: String, trim: true },
type: {
type: String,
enum: ['fullAdvancePayment', 'earlyBird', 'custom'],
required: true
},
percent: { type: Number, min: 0, max: 100 },
fixedAmount: { type: Number, min: 0 },
validFrom: { type: Date },
validTo: { type: Date },
isActive: { type: Boolean, default: true }
});
const courseSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
index: true
},
description: {
type: String,
trim: true
},
type: {
type: String,
enum: ['General', 'Private'],
required: true
},
price: {
type: Number,
required: true,
min: 0
},
rating: {
type: Number,
default: 0,
min: 0,
max: 5
},
isOfficial: {
type: Boolean,
default: false
},
/** When true, course appears on the public website */
showOnFrontend: {
type: Boolean,
default: true,
index: true
},
/** Number of sessions in the course */
sectionCount: {
type: Number,
default: 1,
min: 1
},
/** Hours per session */
hoursPerSection: {
type: Number,
default: 1.5,
min: 0
},
/** Bullet-point highlights shown on the frontend */
highlights: [{
type: String,
trim: true
}],
professor: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Professor'
},
discounts: [discountSchema],
offers: [offerSchema],
capacity: {
type: Number,
default: 30
}
}, {
timestamps: true
});
module.exports = mongoose.model('Course', courseSchema);
+24
View File
@@ -0,0 +1,24 @@
// /components/courses/courseRoutes.js
const express = require('express');
const courseController = require('./courseController');
const { validateCreateCourse, validateUpdateCourse } = require('./courseValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
// User Scope (Public or Authenticated reading)
router.get('/user/get-all', courseController.getAll);
router.get('/user/get-one/:id', courseController.getOne);
// Admin Scope
router.post('/admin/create', authMiddleware, perm.requires(PERMISSIONS.COURSES_CREATE), validateCreateCourse, courseController.create);
router.get('/admin/get-all', authMiddleware, perm.requires(PERMISSIONS.COURSES_READ), courseController.getAll);
router.get('/admin/search', authMiddleware, perm.requires(PERMISSIONS.COURSES_SEARCH), courseController.search);
router.get('/admin/get-one/:id', authMiddleware, perm.requires(PERMISSIONS.COURSES_READ), courseController.getOne);
router.put('/admin/update/:id', authMiddleware, perm.requires(PERMISSIONS.COURSES_UPDATE), validateUpdateCourse, courseController.update);
router.delete('/admin/delete/:id', authMiddleware, perm.requires(PERMISSIONS.COURSES_DELETE), courseController.delete);
module.exports = router;
+139
View File
@@ -0,0 +1,139 @@
// /components/courses/courseService.js
const Course = require('./courseModel');
const Professor = require('../professors/professorModel');
const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const normalizeHighlights = (value) => {
if (!Array.isArray(value)) return undefined;
return value.map((item) => String(item).trim()).filter(Boolean);
};
const createCourse = async (data, actorId = null) => {
if (data.professor) {
const professor = await Professor.findById(data.professor);
if (!professor) {
throw new AppError('PROFESSOR_NOT_FOUND');
}
}
const payload = {
...data,
highlights: normalizeHighlights(data.highlights) ?? data.highlights
};
const course = await Course.create(payload);
if (data.professor) {
await Professor.findByIdAndUpdate(data.professor, { $addToSet: { courses: course._id } });
}
eventEmitter.emit(EVENT_NAMES.COURSE_CREATED, { courseId: course._id, title: course.title, actorId });
return course;
};
const getCourseById = async (id, { publicOnly = false } = {}) => {
const filter = { _id: id };
if (publicOnly) filter.showOnFrontend = { $ne: false };
const course = await Course.findOne(filter).populate('professor', 'name surname title expertise email phoneNumber');
if (!course) {
throw new AppError('COURSE_NOT_FOUND');
}
return course;
};
const getAllCourses = async (queryParams, { publicOnly = false } = {}) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['title', 'description'], [
'page', 'limit', 'sortBy', 'sortOrder', 'q', 'lang', 'sort'
]);
if (publicOnly) {
// Include legacy docs that predate the field (treat missing as visible)
filter.showOnFrontend = { $ne: false };
}
// Support legacy ?sort=-createdAt style from frontend
let finalSort = sort;
if (queryParams.sort && typeof queryParams.sort === 'string') {
const raw = queryParams.sort.trim();
if (raw.startsWith('-')) {
finalSort = { [raw.slice(1)]: -1 };
} else {
finalSort = { [raw]: 1 };
}
}
const [courses, totalCount] = await Promise.all([
Course.find(filter).populate('professor', 'name surname').sort(finalSort).skip(skip).limit(limit),
Course.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit);
return { data: courses, meta };
};
const updateCourse = async (id, updateData, actorId = null) => {
const course = await Course.findById(id);
if (!course) {
throw new AppError('COURSE_NOT_FOUND');
}
if (updateData.professor && updateData.professor !== String(course.professor)) {
const professor = await Professor.findById(updateData.professor);
if (!professor) throw new AppError('PROFESSOR_NOT_FOUND');
if (course.professor) {
await Professor.findByIdAndUpdate(course.professor, { $pull: { courses: course._id } });
}
await Professor.findByIdAndUpdate(updateData.professor, { $addToSet: { courses: course._id } });
}
if (updateData.price !== undefined && updateData.price !== course.price) {
eventEmitter.emit(EVENT_NAMES.COURSE_PRICE_CHANGED, {
courseId: course._id,
oldPrice: course.price,
newPrice: updateData.price,
actorId
});
}
if (updateData.highlights !== undefined) {
updateData.highlights = normalizeHighlights(updateData.highlights) || [];
}
Object.assign(course, updateData);
await course.save();
return course;
};
const deleteCourse = async (id) => {
const course = await Course.findById(id);
if (!course) {
throw new AppError('COURSE_NOT_FOUND');
}
if (course.professor) {
await Professor.findByIdAndUpdate(course.professor, { $pull: { courses: course._id } });
}
await Course.findByIdAndDelete(id);
return null;
};
const searchCourses = async (queryParams) => {
return getAllCourses(queryParams);
};
module.exports = {
createCourse,
getCourseById,
getAllCourses,
updateCourse,
deleteCourse,
searchCourses
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
@@ -0,0 +1,11 @@
// /components/dashboard/dashboardController.js
'use strict';
const catchAsync = require('../../utils/catchAsync');
const dashboardService = require('./dashboardService');
const { successResponse } = require('../../utils/apiResponse');
exports.getAdminStats = catchAsync(async (req, res, next) => {
const stats = await dashboardService.getAdminStats();
return successResponse(res, 200, 'Dashboard stats retrieved successfully', stats);
});
+15
View File
@@ -0,0 +1,15 @@
// /components/dashboard/dashboardRoutes.js
'use strict';
const express = require('express');
const dashboardController = require('./dashboardController');
const authMiddleware = require('../../middlewares/authMiddleware');
const router = express.Router();
router.use(authMiddleware);
// GET /api/dashboard/admin/stats
router.get('/admin/stats', dashboardController.getAdminStats);
module.exports = router;
+123
View File
@@ -0,0 +1,123 @@
// /components/dashboard/dashboardService.js
'use strict';
const User = require('../users/userModel');
const Professor = require('../professors/professorModel');
const Course = require('../courses/courseModel');
const Session = require('../sessions/sessionModel');
/**
* Returns aggregated statistics for the admin dashboard
*/
const getAdminStats = async () => {
const [
totalUsers,
activeUsers,
totalProfessors,
activeProfessors,
totalCourses,
totalSessions,
recentSessions
] = await Promise.all([
User.countDocuments({}),
User.countDocuments({ isActive: true }),
Professor.countDocuments({}),
Professor.countDocuments({ isActive: true }),
Course.countDocuments({}),
Session.countDocuments({}),
Session.find({})
.sort({ day: -1 })
.limit(8)
.populate('course', 'title type')
.populate('class', 'name')
.populate('professor', 'name surname')
.lean()
]);
// Daily enrollment trend (last 14 days via User.createdAt)
const daysBack = 13;
const rangeStart = new Date();
rangeStart.setHours(0, 0, 0, 0);
rangeStart.setDate(rangeStart.getDate() - daysBack);
const dailyUsersRaw = await User.aggregate([
{ $match: { createdAt: { $gte: rangeStart } } },
{
$group: {
_id: {
year: { $year: '$createdAt' },
month: { $month: '$createdAt' },
day: { $dayOfMonth: '$createdAt' }
},
count: { $sum: 1 }
}
},
{ $sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1 } }
]);
// Fill every day in the range so the chart stays continuous
const dailyUsers = [];
for (let i = 0; i <= daysBack; i += 1) {
const date = new Date(rangeStart);
date.setDate(rangeStart.getDate() + i);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const match = dailyUsersRaw.find(
(item) => item._id.year === year && item._id.month === month && item._id.day === day
);
dailyUsers.push({
_id: { year, month, day },
date: date.toISOString(),
count: match ? match.count : 0
});
}
// Daily sessions trend (same window)
const dailySessionsRaw = await Session.aggregate([
{ $match: { createdAt: { $gte: rangeStart } } },
{
$group: {
_id: {
year: { $year: '$createdAt' },
month: { $month: '$createdAt' },
day: { $dayOfMonth: '$createdAt' }
},
count: { $sum: 1 }
}
},
{ $sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1 } }
]);
const dailySessions = dailyUsers.map((day) => {
const match = dailySessionsRaw.find(
(item) =>
item._id.year === day._id.year &&
item._id.month === day._id.month &&
item._id.day === day._id.day
);
return {
_id: day._id,
date: day.date,
count: match ? match.count : 0
};
});
return {
totals: {
users: totalUsers,
activeUsers,
professors: totalProfessors,
activeProfessors,
courses: totalCourses,
sessions: totalSessions
},
recentSessions,
charts: {
dailyUsers,
dailySessions
}
};
};
module.exports = { getAdminStats };
+36
View File
@@ -0,0 +1,36 @@
// /components/files/fileController.js
const catchAsync = require('../../utils/catchAsync');
const fileService = require('./fileService');
const { successResponse } = require('../../utils/apiResponse');
const AppError = require('../../utils/AppError');
exports.uploadTemp = catchAsync(async (req, res, next) => {
if (!req.file) {
return next(new AppError('FILE_REQUIRED'));
}
const result = await fileService.uploadTempFile(
req.file.buffer,
req.file.originalname,
req.file.mimetype
);
return successResponse(res, 201, 'File uploaded to temp bucket successfully', result);
});
exports.getSignedUrl = catchAsync(async (req, res, next) => {
const { filename } = req.params;
const { bucket } = req.query;
const result = await fileService.getPresignedUrl(filename, bucket);
return successResponse(res, 200, 'Temporary presigned URL generated successfully', result);
});
exports.deleteFile = catchAsync(async (req, res, next) => {
const { filename } = req.params;
const { bucket } = req.query;
await fileService.deleteFile(filename, bucket);
return successResponse(res, 200, 'File deleted successfully');
});
+30
View File
@@ -0,0 +1,30 @@
// /components/files/fileRoutes.js
const express = require('express');
const multer = require('multer');
const fileController = require('./fileController');
const { validateGetSignedUrl } = require('./fileValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
// Memory Storage for Multer to stream directly to S3 temp bucket
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 25 * 1024 * 1024 } // 25MB limit
});
router.use(authMiddleware);
// User Scope
router.post('/user/upload-temp', perm.requires(PERMISSIONS.FILES_UPLOAD), upload.single('file'), fileController.uploadTemp);
router.get('/user/signed-url/:filename', perm.requires(PERMISSIONS.FILES_READ), validateGetSignedUrl, fileController.getSignedUrl);
// Admin Scope
router.post('/admin/upload-temp', perm.requires(PERMISSIONS.FILES_UPLOAD), upload.single('file'), fileController.uploadTemp);
router.get('/admin/signed-url/:filename', perm.requires(PERMISSIONS.FILES_READ), validateGetSignedUrl, fileController.getSignedUrl);
router.delete('/admin/delete/:filename', perm.requires(PERMISSIONS.FILES_DELETE), fileController.deleteFile);
module.exports = router;
+55
View File
@@ -0,0 +1,55 @@
// /components/files/fileService.js
const path = require('path');
const {
uploadToTempBucket,
commitTempFile,
generatePresignedUrl,
deleteFromBucket
} = require('../../utils/s3Client');
const AppError = require('../../utils/AppError');
const uploadTempFile = async (fileBuffer, originalName, mimeType) => {
if (!fileBuffer || !originalName) {
throw new AppError('FILE_REQUIRED');
}
const ext = path.extname(originalName);
const uniquePrefix = `${Date.now()}-${Math.round(Math.random() * 1E9)}`;
const tempFileName = `temp-${uniquePrefix}${ext}`;
const result = await uploadToTempBucket(fileBuffer, tempFileName, mimeType);
return {
tempFileName: result.tempFileName,
originalName,
mimeType
};
};
const getPresignedUrl = async (filename, bucket = null) => {
if (!filename) {
throw new AppError('VALIDATION_FAILED', null, 'Filename is required');
}
const signedUrl = await generatePresignedUrl(filename, bucket || undefined);
return {
filename,
presignedUrl: signedUrl,
expiresInSeconds: 900
};
};
const commitFile = async (tempFileName, targetFileName = null) => {
return commitTempFile(tempFileName, targetFileName);
};
const deleteFile = async (filename, bucket = null) => {
return deleteFromBucket(filename, bucket);
};
module.exports = {
uploadTempFile,
getPresignedUrl,
commitFile,
deleteFile
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
@@ -0,0 +1,45 @@
// /components/notifications/notificationController.js
const catchAsync = require('../../utils/catchAsync');
const notificationService = require('./notificationService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res, next) => {
const notification = await notificationService.createNotification(req.body);
return successResponse(res, 201, 'Notification created successfully', notification);
});
exports.getOne = catchAsync(async (req, res, next) => {
const notification = await notificationService.getNotificationById(req.params.id);
return successResponse(res, 200, 'Notification retrieved successfully', notification);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await notificationService.getAllNotifications(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const notification = await notificationService.updateNotification(req.params.id, req.body);
return successResponse(res, 200, 'Notification updated successfully', notification);
});
exports.delete = catchAsync(async (req, res, next) => {
await notificationService.deleteNotification(req.params.id);
return successResponse(res, 200, 'Notification deleted successfully');
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await notificationService.searchNotifications(req.query);
return listResponse(res, 200, data, meta);
});
exports.retry = catchAsync(async (req, res, next) => {
const result = await notificationService.retryNotification(req.params.id);
return successResponse(res, 200, 'Notification retried successfully', result);
});
exports.getMyNotifications = catchAsync(async (req, res, next) => {
const { data, meta } = await notificationService.getMyNotifications(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
@@ -0,0 +1,57 @@
// /components/notifications/notificationModel.js
const mongoose = require('mongoose');
const notificationSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
index: true
},
channel: {
type: String,
enum: ['email', 'sms', 'baleBot'],
required: true
},
subject: {
type: String,
trim: true
},
body: {
type: String,
required: true,
trim: true
},
status: {
type: String,
enum: ['pending', 'sent', 'delivered', 'failed', 'retrying'],
default: 'pending',
index: true
},
retryCount: {
type: Number,
default: 0
},
maxRetries: {
type: Number,
default: 3
},
lastError: {
type: String,
trim: true
},
sentAt: {
type: Date
},
deliveredAt: {
type: Date
},
relatedEvent: {
type: String,
trim: true
}
}, {
timestamps: true
});
module.exports = mongoose.model('Notification', notificationSchema);
@@ -0,0 +1,26 @@
// /components/notifications/notificationRoutes.js
const express = require('express');
const notificationController = require('./notificationController');
const { validateCreateNotification, validateUpdateNotification } = require('./notificationValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
// User Scope
router.get('/user/my-notifications', notificationController.getMyNotifications);
// Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.NOTIFICATIONS_CREATE), validateCreateNotification, notificationController.create);
router.get('/admin/get-all', perm.requires(PERMISSIONS.NOTIFICATIONS_READ), notificationController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.NOTIFICATIONS_SEARCH), notificationController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.NOTIFICATIONS_READ), notificationController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.NOTIFICATIONS_UPDATE), validateUpdateNotification, notificationController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.NOTIFICATIONS_DELETE), notificationController.delete);
router.post('/admin/:id/retry', perm.requires(PERMISSIONS.NOTIFICATIONS_RETRY), notificationController.retry);
module.exports = router;
@@ -0,0 +1,138 @@
// /components/notifications/notificationService.js
const Notification = require('./notificationModel');
const User = require('../users/userModel');
const AppError = require('../../utils/AppError');
const { sendEmail } = require('../../utils/senders/emailSender');
const { sendSMS } = require('../../utils/senders/smsSender');
const { sendBaleMessage } = require('../../utils/senders/baleBotSender');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const createNotification = async (data) => {
const user = await User.findById(data.user);
if (!user) throw new AppError('USER_NOT_FOUND');
const notification = await Notification.create(data);
// Attempt sending immediately
try {
if (data.channel === 'email' && user.email) {
await sendEmail({ to: user.email, subject: data.subject, body: data.body });
} else if (data.channel === 'baleBot') {
await sendBaleMessage({ chatId: user.phoneNumber, body: data.body });
} else {
await sendSMS({ phoneNumber: user.phoneNumber, body: data.body });
}
notification.status = 'sent';
notification.sentAt = new Date();
await notification.save();
} catch (err) {
notification.status = 'failed';
notification.lastError = err.message;
notification.retryCount = 1;
await notification.save();
}
return notification;
};
const getNotificationById = async (id) => {
const notification = await Notification.findById(id).populate('user', 'name surname username email phoneNumber');
if (!notification) {
throw new AppError('NOTIFICATION_NOT_FOUND');
}
return notification;
};
const getAllNotifications = async (queryParams) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['subject', 'body']);
const [notifications, totalCount] = await Promise.all([
Notification.find(filter).populate('user', 'name surname username').sort(sort).skip(skip).limit(limit),
Notification.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit);
return { data: notifications, meta };
};
const updateNotification = async (id, updateData) => {
const notification = await Notification.findById(id);
if (!notification) {
throw new AppError('NOTIFICATION_NOT_FOUND');
}
Object.assign(notification, updateData);
await notification.save();
return notification;
};
const deleteNotification = async (id) => {
const notification = await Notification.findById(id);
if (!notification) {
throw new AppError('NOTIFICATION_NOT_FOUND');
}
await Notification.findByIdAndDelete(id);
return null;
};
const searchNotifications = async (queryParams) => {
return getAllNotifications(queryParams);
};
const retryNotification = async (id) => {
const notification = await Notification.findById(id).populate('user');
if (!notification) {
throw new AppError('NOTIFICATION_NOT_FOUND');
}
const user = notification.user;
if (!user) throw new AppError('USER_NOT_FOUND');
notification.status = 'retrying';
notification.retryCount += 1;
await notification.save();
try {
if (notification.channel === 'email' && user.email) {
await sendEmail({ to: user.email, subject: notification.subject, body: notification.body });
} else if (notification.channel === 'baleBot') {
await sendBaleMessage({ chatId: user.phoneNumber, body: notification.body });
} else {
await sendSMS({ phoneNumber: user.phoneNumber, body: notification.body });
}
notification.status = 'sent';
notification.sentAt = new Date();
await notification.save();
return { success: true, notification };
} catch (err) {
notification.status = 'failed';
notification.lastError = err.message;
await notification.save();
throw new Error(`Retry attempt ${notification.retryCount} failed: ${err.message}`);
}
};
const getMyNotifications = async (userId, queryParams) => {
const filter = { user: userId, ...buildFilterQuery(queryParams) };
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const [notifications, totalCount] = await Promise.all([
Notification.find(filter).sort(sort).skip(skip).limit(limit),
Notification.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit);
return { data: notifications, meta };
};
module.exports = {
createNotification,
getNotificationById,
getAllNotifications,
updateNotification,
deleteNotification,
searchNotifications,
retryNotification,
getMyNotifications
};
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+48
View File
@@ -0,0 +1,48 @@
// /components/payments/paymentController.js
const catchAsync = require('../../utils/catchAsync');
const paymentService = require('./paymentService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const payment = await paymentService.createPayment(req.body, actorId);
return successResponse(res, 201, 'Payment created successfully', payment);
});
exports.getOne = catchAsync(async (req, res, next) => {
const payment = await paymentService.getPaymentById(req.params.id);
return successResponse(res, 200, 'Payment retrieved successfully', payment);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await paymentService.getAllPayments(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const payment = await paymentService.updatePayment(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Payment updated successfully', payment);
});
exports.delete = catchAsync(async (req, res, next) => {
await paymentService.deletePayment(req.params.id);
return successResponse(res, 200, 'Payment deleted successfully');
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await paymentService.searchPayments(req.query);
return listResponse(res, 200, data, meta);
});
exports.payUser = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const payment = await paymentService.addTransaction(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Payment transaction recorded', payment);
});
exports.getMyPayments = catchAsync(async (req, res, next) => {
const { data, meta } = await paymentService.getMyPayments(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
+71
View File
@@ -0,0 +1,71 @@
// /components/payments/paymentModel.js
'use strict';
const mongoose = require('mongoose');
const transactionSchema = new mongoose.Schema({
amount: { type: Number, required: true },
method: {
type: String,
enum: ['online', 'card', 'cash'],
default: 'card'
},
receiptNumber: { type: String, trim: true },
recordedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
date: { type: Date, default: Date.now }
}, { _id: true });
const paymentSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
classes: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Class'
}],
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course'
},
amount: {
type: Number,
required: true,
min: 0
},
paidAmount: {
type: Number,
default: 0,
min: 0
},
dueDate: {
type: Date
},
status: {
type: String,
enum: ['pending', 'partial', 'paid', 'overdue'],
default: 'pending'
},
transactions: [transactionSchema],
notes: { type: String, trim: true }
}, {
timestamps: true
});
// Auto-update status based on paid amount
paymentSchema.pre('save', function (next) {
if (this.paidAmount >= this.amount) {
this.status = 'paid';
} else if (this.paidAmount > 0) {
this.status = 'partial';
} else if (this.dueDate && new Date() > this.dueDate) {
this.status = 'overdue';
} else {
this.status = 'pending';
}
next();
});
module.exports = mongoose.model('Payment', paymentSchema);
+30
View File
@@ -0,0 +1,30 @@
// /components/payments/paymentRoutes.js
const express = require('express');
const paymentController = require('./paymentController');
const {
validateCreatePayment,
validateUpdatePayment,
validateAddTransaction
} = require('./paymentValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
// User Scope
router.get('/user/my-payments', paymentController.getMyPayments);
router.post('/user/pay/:id', validateAddTransaction, paymentController.payUser);
// Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.PAYMENTS_CREATE), validateCreatePayment, paymentController.create);
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/get-one/:id', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdatePayment, paymentController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete);
module.exports = router;
+120
View File
@@ -0,0 +1,120 @@
// /components/payments/paymentService.js
'use strict';
const Payment = require('./paymentModel');
const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { calculateMeta } = require('../../utils/pagination');
const getAllPayments = async (query) => {
const page = parseInt(query.page) || 1;
const limit = Math.min(parseInt(query.limit) || 20, 200);
const skip = (page - 1) * limit;
const filter = {};
if (query.userId) filter.user = query.userId;
if (query.status) filter.status = query.status;
const [items, total] = await Promise.all([
Payment.find(filter)
.populate({ path: 'user', select: 'name surname' })
.populate({ path: 'classes', select: 'name' })
.populate({ path: 'course', select: 'title' })
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
Payment.countDocuments(filter)
]);
return { data: items, meta: calculateMeta(total, page, limit) };
};
const getPaymentById = async (id) => {
const payment = await Payment.findById(id)
.populate({ path: 'user', select: 'name surname phoneNumber' })
.populate({ path: 'classes', select: 'name tuitionFee' })
.populate({ path: 'course', select: 'title price' })
.lean();
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
return payment;
};
const createPayment = async (body, actorId = null) => {
const payment = await Payment.create({
...body,
paidAmount: body.paidAmount || 0
});
if (actorId) {
eventEmitter.emit(EVENT_NAMES.PAYMENT_CREATED, {
paymentId: payment._id,
userId: payment.user,
actorId
});
}
return getPaymentById(payment._id);
};
const updatePayment = async (id, body, actorId = null) => {
const payment = await Payment.findById(id);
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
const previousStatus = payment.status;
Object.assign(payment, body);
await payment.save();
if (body.status && body.status !== previousStatus) {
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
paymentId: payment._id,
userId: payment.user,
oldStatus: previousStatus,
newStatus: payment.status,
actorId
});
}
return getPaymentById(payment._id);
};
const deletePayment = async (id) => {
const payment = await Payment.findByIdAndDelete(id);
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
return null;
};
const searchPayments = async (query) => getAllPayments(query);
const addTransaction = async (paymentId, trxData, actorId = null) => {
const payment = await Payment.findById(paymentId);
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
payment.transactions.push({
...trxData,
recordedBy: actorId || trxData.recordedBy,
date: trxData.date || new Date()
});
payment.paidAmount = payment.transactions.reduce((sum, t) => sum + (t.amount || 0), 0);
await payment.save();
return getPaymentById(paymentId);
};
const getMyPayments = async (userId, query = {}) => {
return getAllPayments({ ...query, userId });
};
module.exports = {
getAllPayments,
getPaymentById,
createPayment,
updatePayment,
deletePayment,
searchPayments,
addTransaction,
getMyPayments,
// Aliases for older call sites
getAll: getAllPayments,
getOne: getPaymentById,
create: createPayment,
recordTransaction: addTransaction,
remove: deletePayment
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
@@ -0,0 +1,35 @@
// /components/professors/professorController.js
const catchAsync = require('../../utils/catchAsync');
const professorService = require('./professorService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res, next) => {
const professor = await professorService.createProfessor(req.body);
return successResponse(res, 201, 'Professor created successfully', professor);
});
exports.getOne = catchAsync(async (req, res, next) => {
const professor = await professorService.getProfessorById(req.params.id);
return successResponse(res, 200, 'Professor retrieved successfully', professor);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await professorService.getAllProfessors(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const professor = await professorService.updateProfessor(req.params.id, req.body);
return successResponse(res, 200, 'Professor updated successfully', professor);
});
exports.delete = catchAsync(async (req, res, next) => {
await professorService.deleteProfessor(req.params.id);
return successResponse(res, 200, 'Professor deleted successfully');
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await professorService.searchProfessors(req.query);
return listResponse(res, 200, data, meta);
});
+51
View File
@@ -0,0 +1,51 @@
// /components/professors/professorModel.js
const mongoose = require('mongoose');
const professorSchema = new mongoose.Schema({
nationalIdCode: {
type: String,
required: true,
unique: true,
trim: true,
index: true
},
name: {
type: String,
required: true,
trim: true
},
surname: {
type: String,
required: true,
trim: true
},
phoneNumber: {
type: String,
required: true,
unique: true,
trim: true,
index: true
},
email: {
type: String,
trim: true,
lowercase: true
},
expertise: [{
type: String,
trim: true
}],
courses: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Course'
}],
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true
});
module.exports = mongoose.model('Professor', professorSchema);
+21
View File
@@ -0,0 +1,21 @@
// /components/professors/professorRoutes.js
const express = require('express');
const professorController = require('./professorController');
const { validateCreateProfessor, validateUpdateProfessor } = require('./professorValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
router.post('/admin/create', perm.requires(PERMISSIONS.PROFESSORS_CREATE), validateCreateProfessor, professorController.create);
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/get-one/:id', perm.requires(PERMISSIONS.PROFESSORS_READ), professorController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.PROFESSORS_UPDATE), validateUpdateProfessor, professorController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PROFESSORS_DELETE), professorController.delete);
module.exports = router;
+79
View File
@@ -0,0 +1,79 @@
// /components/professors/professorService.js
const Professor = require('./professorModel');
const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const createProfessor = async (data) => {
const existing = await Professor.findOne({
$or: [
{ nationalIdCode: data.nationalIdCode },
{ phoneNumber: data.phoneNumber },
...(data.email ? [{ email: data.email }] : [])
]
});
if (existing) {
throw new AppError('PROFESSOR_ALREADY_EXISTS');
}
const professor = await Professor.create(data);
eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, { professorId: professor._id, name: `${professor.name} ${professor.surname}` });
return professor;
};
const getProfessorById = async (id) => {
const professor = await Professor.findById(id).populate('courses', 'title type price');
if (!professor) {
throw new AppError('PROFESSOR_NOT_FOUND');
}
return professor;
};
const getAllProfessors = async (queryParams) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['name', 'surname', 'nationalIdCode', 'phoneNumber', 'email', 'expertise']);
const [professors, totalCount] = await Promise.all([
Professor.find(filter).populate('courses', 'title').sort(sort).skip(skip).limit(limit),
Professor.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit);
return { data: professors, meta };
};
const updateProfessor = async (id, updateData) => {
const professor = await Professor.findById(id);
if (!professor) {
throw new AppError('PROFESSOR_NOT_FOUND');
}
Object.assign(professor, updateData);
await professor.save();
return professor;
};
const deleteProfessor = async (id) => {
const professor = await Professor.findById(id);
if (!professor) {
throw new AppError('PROFESSOR_NOT_FOUND');
}
await Professor.findByIdAndDelete(id);
return null;
};
const searchProfessors = async (queryParams) => {
return getAllProfessors(queryParams);
};
module.exports = {
createProfessor,
getProfessorById,
getAllProfessors,
updateProfessor,
deleteProfessor,
searchProfessors
};
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+35
View File
@@ -0,0 +1,35 @@
// /components/roles/roleController.js
const catchAsync = require('../../utils/catchAsync');
const roleService = require('./roleService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.createRole = catchAsync(async (req, res, next) => {
const role = await roleService.createRole(req.body);
return successResponse(res, 201, 'Role created successfully', role);
});
exports.getRole = catchAsync(async (req, res, next) => {
const role = await roleService.getRole(req.params.id);
return successResponse(res, 200, 'Role retrieved successfully', role);
});
exports.getAllRoles = catchAsync(async (req, res, next) => {
const { data, meta } = await roleService.getAllRoles(req.query);
return listResponse(res, 200, data, meta);
});
exports.updateRole = catchAsync(async (req, res, next) => {
const role = await roleService.updateRole(req.params.id, req.body);
return successResponse(res, 200, 'Role updated successfully', role);
});
exports.deleteRole = catchAsync(async (req, res, next) => {
await roleService.deleteRole(req.params.id);
return successResponse(res, 200, 'Role deleted successfully');
});
exports.searchRoles = catchAsync(async (req, res, next) => {
const { data, meta } = await roleService.searchRoles(req.query);
return listResponse(res, 200, data, meta);
});
+29
View File
@@ -0,0 +1,29 @@
// /components/roles/roleModel.js
const mongoose = require('mongoose');
const roleSchema = new mongoose.Schema({
name: {
type: String,
required: true,
unique: true,
trim: true,
index: true
},
description: {
type: String,
trim: true
},
permissions: [{
type: String,
trim: true
}],
isSystem: {
type: Boolean,
default: false
}
}, {
timestamps: true
});
module.exports = mongoose.model('Role', roleSchema);
+21
View File
@@ -0,0 +1,21 @@
// /components/roles/roleRoutes.js
const express = require('express');
const roleController = require('./roleController');
const { validateCreateRole, validateUpdateRole } = require('./roleValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
router.post('/admin/create', perm.requires(PERMISSIONS.ROLES_CREATE), validateCreateRole, roleController.createRole);
router.get('/admin/get-all', perm.requires(PERMISSIONS.ROLES_READ), roleController.getAllRoles);
router.get('/admin/search', perm.requires(PERMISSIONS.ROLES_SEARCH), roleController.searchRoles);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.ROLES_READ), roleController.getRole);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.ROLES_UPDATE), validateUpdateRole, roleController.updateRole);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.ROLES_DELETE), roleController.deleteRole);
module.exports = router;
+84
View File
@@ -0,0 +1,84 @@
// /components/roles/roleService.js
const Role = require('./roleModel');
const AppError = require('../../utils/AppError');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const createRole = async (roleData) => {
const existing = await Role.findOne({ name: roleData.name });
if (existing) {
throw new AppError('ROLE_ALREADY_EXISTS');
}
const role = await Role.create(roleData);
return role;
};
const getRole = async (id) => {
const role = await Role.findById(id);
if (!role) {
throw new AppError('ROLE_NOT_FOUND');
}
return role;
};
const getAllRoles = async (queryParams = {}) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['name', 'description']);
const [roles, totalCount] = await Promise.all([
Role.find(filter).sort(sort).skip(skip).limit(limit),
Role.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit);
return { data: roles, meta };
};
const updateRole = async (id, updateData) => {
const role = await Role.findById(id);
if (!role) {
throw new AppError('ROLE_NOT_FOUND');
}
if (role.isSystem) {
throw new AppError('SYSTEM_ROLE_PROTECTED');
}
if (updateData.name && updateData.name !== role.name) {
const existing = await Role.findOne({ name: updateData.name });
if (existing) {
throw new AppError('ROLE_ALREADY_EXISTS');
}
}
Object.assign(role, updateData);
await role.save();
return role;
};
const deleteRole = async (id) => {
const role = await Role.findById(id);
if (!role) {
throw new AppError('ROLE_NOT_FOUND');
}
if (role.isSystem) {
throw new AppError('SYSTEM_ROLE_PROTECTED');
}
await Role.findByIdAndDelete(id);
return null;
};
const searchRoles = async (queryParams) => {
return getAllRoles(queryParams);
};
module.exports = {
createRole,
getRole,
getAllRoles,
updateRole,
deleteRole,
searchRoles
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+63
View File
@@ -0,0 +1,63 @@
// /components/sessions/sessionController.js
const catchAsync = require('../../utils/catchAsync');
const sessionService = require('./sessionService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const session = await sessionService.createSession(req.body, actorId);
return successResponse(res, 201, 'Session created successfully', session);
});
exports.getOne = catchAsync(async (req, res, next) => {
const session = await sessionService.getSessionById(req.params.id);
return successResponse(res, 200, 'Session retrieved successfully', session);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await sessionService.getAllSessions(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const session = await sessionService.updateSession(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Session updated successfully', session);
});
exports.delete = catchAsync(async (req, res, next) => {
await sessionService.deleteSession(req.params.id);
return successResponse(res, 200, 'Session deleted successfully');
});
exports.bulkDelete = catchAsync(async (req, res, next) => {
const result = await sessionService.bulkDeleteSessions(req.body.ids || req.body.sessionIds);
return successResponse(res, 200, 'Sessions deleted successfully', result);
});
exports.bulkUpdateStatus = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const result = await sessionService.bulkUpdateSessionStatus(
req.body.ids || req.body.sessionIds,
req.body.status,
actorId
);
return successResponse(res, 200, 'Session statuses updated successfully', result);
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await sessionService.searchSessions(req.query);
return listResponse(res, 200, data, meta);
});
exports.updateAttendance = catchAsync(async (req, res, next) => {
const recordedBy = req.user?._id;
const session = await sessionService.updateSessionAttendance(req.params.id, req.body.attendanceList, recordedBy);
return successResponse(res, 200, 'Session attendance updated successfully', session);
});
exports.getMySessions = catchAsync(async (req, res, next) => {
const { data, meta } = await sessionService.getMySessions(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
+89
View File
@@ -0,0 +1,89 @@
// /components/sessions/sessionModel.js
const mongoose = require('mongoose');
const attendanceRecordSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
status: {
type: String,
enum: ['present', 'absent', 'late', 'excused'],
default: 'present'
},
note: {
type: String,
trim: true
},
recordedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}
}, {
_id: false
});
const sessionSchema = new mongoose.Schema({
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course',
required: true,
index: true
},
class: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Class',
required: true,
index: true
},
professor: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Professor',
required: true,
index: true
},
day: {
type: Date,
required: true,
index: true
},
startTime: {
type: String,
required: true,
trim: true
},
endTime: {
type: String,
required: true,
trim: true
},
place: {
type: String,
trim: true
},
topic: {
type: String,
trim: true
},
note: {
type: String,
trim: true,
maxlength: 5000
},
status: {
type: String,
enum: ['scheduled', 'held', 'cancelled'],
default: 'scheduled'
},
attendanceList: [attendanceRecordSchema],
reminderSentAt: {
type: Date,
default: null
}
}, {
timestamps: true
});
module.exports = mongoose.model('Session', sessionSchema);
+32
View File
@@ -0,0 +1,32 @@
// /components/sessions/sessionRoutes.js
const express = require('express');
const sessionController = require('./sessionController');
const {
validateCreateSession,
validateUpdateSession,
validateUpdateAttendanceList
} = require('./sessionValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
// User Scope
router.get('/user/my-sessions', sessionController.getMySessions);
// Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.SESSIONS_CREATE), validateCreateSession, sessionController.create);
router.get('/admin/get-all', perm.requires(PERMISSIONS.SESSIONS_READ), sessionController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.SESSIONS_SEARCH), sessionController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.SESSIONS_READ), sessionController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.SESSIONS_UPDATE), validateUpdateSession, sessionController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.SESSIONS_DELETE), sessionController.delete);
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.put('/admin/:id/attendance', perm.requires(PERMISSIONS.SESSIONS_ATTENDANCE), validateUpdateAttendanceList, sessionController.updateAttendance);
module.exports = router;
+299
View File
@@ -0,0 +1,299 @@
// /components/sessions/sessionService.js
const Session = require('./sessionModel');
const Course = require('../courses/courseModel');
const Class = require('../classes/classModel');
const Professor = require('../professors/professorModel');
const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const STATUS_MAP = {
scheduled: 'scheduled',
held: 'held',
cancelled: 'cancelled',
Scheduled: 'scheduled',
Held: 'held',
Cancelled: 'cancelled',
canceled: 'cancelled',
Canceled: 'cancelled'
};
const normalizeSessionPayload = (data) => {
const payload = { ...data };
if (payload.date && !payload.day) {
payload.day = payload.date;
}
delete payload.date;
if (payload.status) {
payload.status = STATUS_MAP[payload.status] || payload.status;
}
return payload;
};
const createSession = async (data, actorId = null) => {
// Support bulk create from dashboard: { sessions: [...], courseId, professorId, classId }
if (Array.isArray(data.sessions)) {
const created = [];
for (const item of data.sessions) {
const session = await createSession({
...item,
course: item.course || data.courseId || data.course,
class: item.class || data.classId || data.class,
professor: item.professor || data.professorId || data.professor
}, actorId);
created.push(session);
}
return created;
}
const payload = normalizeSessionPayload(data);
if (!payload.day) {
throw new AppError('VALIDATION_FAILED', { day: 'Date is required' }, 'تاریخ جلسه الزامی است.');
}
if (!payload.startTime || !payload.endTime) {
throw new AppError('VALIDATION_FAILED', { time: 'Start and end time are required' }, 'ساعت شروع و پایان الزامی است.');
}
const course = await Course.findById(payload.course);
if (!course) throw new AppError('COURSE_NOT_FOUND');
const classItem = await Class.findById(payload.class);
if (!classItem) throw new AppError('NOT_FOUND', null, 'کلاس یافت نشد.');
const professor = await Professor.findById(payload.professor);
if (!professor) throw new AppError('PROFESSOR_NOT_FOUND');
const session = await Session.create(payload);
eventEmitter.emit(EVENT_NAMES.SESSION_CREATED, {
sessionId: session._id,
courseId: session.course,
classId: session.class,
actorId
});
return session;
};
const getSessionById = async (id) => {
const session = await Session.findById(id)
.populate('course', 'title type')
.populate({
path: 'class',
select: 'name students capacity',
populate: { path: 'students', select: 'name surname nationalIdCode phoneNumber' }
})
.populate('professor', 'name surname email phoneNumber')
.populate('attendanceList.user', 'name surname username nationalIdCode');
if (!session) {
throw new AppError('SESSION_NOT_FOUND');
}
return session;
};
const startOfToday = () => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
};
/** Upcoming soonest first, then most recent past — nearest attendance date. */
const sortByClosestAttendance = (sessions) => {
const today = startOfToday().getTime();
return [...sessions].sort((a, b) => {
const da = new Date(a.day || a.date || 0).getTime();
const db = new Date(b.day || b.date || 0).getTime();
const aUpcoming = da >= today;
const bUpcoming = db >= today;
if (aUpcoming && bUpcoming) return da - db;
if (!aUpcoming && !bUpcoming) return db - da;
return aUpcoming ? -1 : 1;
});
};
const populateSessionList = (query) =>
query
.populate('course', 'title type')
.populate('class', 'name')
.populate('professor', 'name surname');
const getAllSessions = async (queryParams) => {
const { page, limit, skip } = parsePaginationAndSort(queryParams, 'day', 'asc');
const filter = buildFilterQuery(queryParams, ['topic', 'place', 'note'], [
'page',
'limit',
'sortBy',
'sortOrder',
'q',
'lang',
'courseId',
'classId',
'professorId',
'class' // handled below so we always cast consistently
]);
if (queryParams.courseId) filter.course = queryParams.courseId;
const classFilter = queryParams.classId || queryParams.class;
if (classFilter) {
filter.class = classFilter;
}
if (queryParams.professorId) filter.professor = queryParams.professorId;
if (filter.status) {
filter.status = STATUS_MAP[filter.status] || filter.status;
}
const [matched, totalCount] = await Promise.all([
populateSessionList(Session.find(filter)).lean(),
Session.countDocuments(filter)
]);
const sessions = sortByClosestAttendance(matched).slice(skip, skip + limit);
const meta = calculateMeta(totalCount, page, limit);
return { data: sessions, meta };
};
const updateSession = async (id, updateData, actorId = null) => {
const session = await Session.findById(id);
if (!session) {
throw new AppError('SESSION_NOT_FOUND');
}
const payload = normalizeSessionPayload(updateData);
if (payload.day === null || payload.day === '') {
throw new AppError('VALIDATION_FAILED', { day: 'Date is required' }, 'تاریخ جلسه الزامی است.');
}
if (payload.status === 'cancelled' && session.status !== 'cancelled') {
eventEmitter.emit(EVENT_NAMES.SESSION_CANCELLED, {
sessionId: session._id,
courseId: session.course,
topic: payload.topic || session.topic,
actorId
});
}
Object.assign(session, payload);
await session.save();
return getSessionById(session._id);
};
const deleteSession = async (id) => {
const session = await Session.findById(id);
if (!session) {
throw new AppError('SESSION_NOT_FOUND');
}
await Session.findByIdAndDelete(id);
return null;
};
const normalizeIds = (ids) => {
if (!Array.isArray(ids)) return [];
return [...new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))];
};
const bulkDeleteSessions = async (ids) => {
const sessionIds = normalizeIds(ids);
if (!sessionIds.length) {
throw new AppError('VALIDATION_FAILED', { ids: 'Required' }, 'هیچ جلسه‌ای انتخاب نشده است.');
}
const result = await Session.deleteMany({ _id: { $in: sessionIds } });
return { deletedCount: result.deletedCount || 0 };
};
const bulkUpdateSessionStatus = async (ids, status, actorId = null) => {
const sessionIds = normalizeIds(ids);
if (!sessionIds.length) {
throw new AppError('VALIDATION_FAILED', { ids: 'Required' }, 'هیچ جلسه‌ای انتخاب نشده است.');
}
const normalizedStatus = STATUS_MAP[status] || status;
if (!['scheduled', 'held', 'cancelled'].includes(normalizedStatus)) {
throw new AppError('VALIDATION_FAILED', { status: 'Invalid' }, 'وضعیت جلسه نامعتبر است.');
}
const sessions = await Session.find({ _id: { $in: sessionIds } });
let updatedCount = 0;
for (const session of sessions) {
const previousStatus = session.status;
session.status = normalizedStatus;
await session.save();
updatedCount += 1;
if (normalizedStatus === 'cancelled' && previousStatus !== 'cancelled') {
eventEmitter.emit(EVENT_NAMES.SESSION_CANCELLED, {
sessionId: session._id,
courseId: session.course,
classId: session.class,
actorId
});
}
}
return { updatedCount, status: normalizedStatus };
};
const searchSessions = async (queryParams) => {
return getAllSessions(queryParams);
};
const updateSessionAttendance = async (sessionId, attendanceList, recordedBy = null) => {
const session = await Session.findById(sessionId);
if (!session) throw new AppError('SESSION_NOT_FOUND');
const list = Array.isArray(attendanceList) ? attendanceList : [];
session.attendanceList = list.map((record) => ({
user: record.user || record.userId,
status: record.status || 'present',
note: record.note || '',
recordedBy
}));
await session.save();
eventEmitter.emit(EVENT_NAMES.ATTENDANCE_RECORDED, {
sessionId: session._id,
recordCount: list.length,
recordedBy
});
return getSessionById(sessionId);
};
const getMySessions = async (userId, queryParams) => {
const userClasses = await Class.find({ students: userId }).select('_id');
const classIds = userClasses.map((c) => c._id);
const filter = { class: { $in: classIds } };
const { page, limit, skip } = parsePaginationAndSort(queryParams, 'day', 'asc');
const [matched, totalCount] = await Promise.all([
populateSessionList(Session.find(filter)).lean(),
Session.countDocuments(filter)
]);
const sessions = sortByClosestAttendance(matched).slice(skip, skip + limit);
const meta = calculateMeta(totalCount, page, limit);
return { data: sessions, meta };
};
module.exports = {
createSession,
getSessionById,
getAllSessions,
updateSession,
deleteSession,
bulkDeleteSessions,
bulkUpdateSessionStatus,
searchSessions,
updateSessionAttendance,
getMySessions
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+61
View File
@@ -0,0 +1,61 @@
// /components/users/userController.js
const catchAsync = require('../../utils/catchAsync');
const userService = require('./userService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.signUp = catchAsync(async (req, res, next) => {
const user = await userService.signUp(req.body);
return successResponse(res, 201, 'Signed up successfully', user);
});
exports.getSelf = catchAsync(async (req, res, next) => {
const user = await userService.getUserById(req.user._id);
return successResponse(res, 200, 'User profile retrieved', user);
});
exports.updateSelf = catchAsync(async (req, res, next) => {
delete req.body.role;
delete req.body.isActive;
const user = await userService.updateUser(req.user._id, req.body);
return successResponse(res, 200, 'Profile updated successfully', user);
});
exports.createAdmin = catchAsync(async (req, res, next) => {
const user = await userService.createUserAdmin(req.body);
return successResponse(res, 201, 'User created successfully', user);
});
exports.getOne = catchAsync(async (req, res, next) => {
const user = await userService.getUserById(req.params.id);
return successResponse(res, 200, 'User retrieved successfully', user);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await userService.getAllUsers(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const user = await userService.updateUser(req.params.id, req.body);
return successResponse(res, 200, 'User updated successfully', user);
});
exports.delete = catchAsync(async (req, res, next) => {
await userService.deleteUser(req.params.id);
return successResponse(res, 200, 'User deleted successfully');
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await userService.searchUsers(req.query);
return listResponse(res, 200, data, meta);
});
exports.enroll = catchAsync(async (req, res, next) => {
const { userId } = req.params;
const { courseId } = req.body;
const actorId = req.user._id;
const result = await userService.enrollUserInCourse(userId, courseId, actorId);
return successResponse(res, 200, 'User enrolled into course successfully', result);
});
+87
View File
@@ -0,0 +1,87 @@
// /components/users/userModel.js
const mongoose = require('mongoose');
const refreshTokenSchema = new mongoose.Schema({
token: { type: String, required: true },
expiresAt: { type: Date, required: true },
createdAt: { type: Date, default: Date.now }
});
const userSchema = new mongoose.Schema({
nationalIdCode: {
type: String,
required: true,
unique: true,
trim: true,
index: true
},
name: {
type: String,
required: true,
trim: true
},
surname: {
type: String,
required: true,
trim: true
},
phoneNumber: {
type: String,
required: true,
unique: true,
trim: true,
index: true
},
preferredMessenger: {
type: [{
type: String,
enum: ['Bale', 'WhatsApp', 'Telegram', 'SMS', 'Email']
}],
default: ['SMS']
},
email: {
type: String,
sparse: true,
trim: true,
lowercase: true,
match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, 'Please fill a valid email address']
},
address: {
type: String,
trim: true
},
username: {
type: String,
required: true,
unique: true,
trim: true,
index: true
},
passwordHash: {
type: String,
required: true
},
role: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Role',
required: true
},
courses: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Course'
}],
certificates: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Certificate'
}],
refreshTokens: [refreshTokenSchema],
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true
});
module.exports = mongoose.model('User', userSchema);
+31
View File
@@ -0,0 +1,31 @@
// /components/users/userRoutes.js
const express = require('express');
const userController = require('./userController');
const {
validateSignUp,
validateCreateUserAdmin,
validateUpdateUser,
validateEnroll
} = require('./userValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
// User Scope (Self-Service)
router.post('/user/sign-up', validateSignUp, userController.signUp);
router.get('/user/get-self', authMiddleware, userController.getSelf);
router.put('/user/update-self', authMiddleware, validateUpdateUser, userController.updateSelf);
// Admin Scope
router.post('/admin/create', authMiddleware, perm.requires(PERMISSIONS.USERS_CREATE), validateCreateUserAdmin, userController.createAdmin);
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/get-one/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getOne);
router.put('/admin/update/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), validateUpdateUser, userController.update);
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);
module.exports = router;
+232
View File
@@ -0,0 +1,232 @@
// /components/users/userService.js
'use strict';
const User = require('./userModel');
const Role = require('../roles/roleModel');
const bcrypt = require('bcryptjs');
const AppError = require('../../utils/AppError');
const { calculateMeta } = require('../../utils/pagination');
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
const { sendAccountCreatedSms } = require('../../utils/senders/smsMessages');
const logger = require('../../utils/logger');
const POPULATE_ROLE = { path: 'role', select: 'name permissions' };
const SAFE_FIELDS = '-passwordHash -refreshTokens';
const ALLOWED_MESSENGERS = ['Bale', 'WhatsApp', 'Telegram', 'SMS', 'Email'];
const normalizePreferredMessengers = (value) => {
if (value == null || value === '') return undefined;
const list = Array.isArray(value) ? value : [value];
const cleaned = [...new Set(list.map(String).filter((v) => ALLOWED_MESSENGERS.includes(v)))];
return cleaned;
};
const allocateUniqueUsername = async (preferred) => {
let username = preferred && String(preferred).trim();
if (username) {
const exists = await User.exists({ username });
if (exists) throw new AppError('USER_ALREADY_EXISTS', null, 'Username already exists');
return username;
}
for (let attempt = 0; attempt < 12; attempt += 1) {
username = generateUsername();
const exists = await User.exists({ username });
if (!exists) return username;
}
throw new AppError('INTERNAL_SERVER_ERROR', null, 'Could not generate a unique username');
};
const signUp = async (body) => {
const { name, surname, nationalId, nationalIdCode, phoneNumber, phone, username, password, email, address, preferredMessenger } = body;
const userRole = await Role.findOne({ name: 'User' });
if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
const passwordHash = await bcrypt.hash(password, 10);
const user = await User.create({
name,
surname,
nationalIdCode: nationalIdCode || nationalId,
phoneNumber: phoneNumber || phone,
username,
passwordHash,
email,
address,
preferredMessenger: normalizePreferredMessengers(preferredMessenger),
role: userRole._id
});
return user.populate(POPULATE_ROLE);
};
const getUserById = async (id) => {
const user = await User.findById(id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean();
if (!user) throw new AppError('USER_NOT_FOUND');
return user;
};
const getAllUsers = async (query) => {
const page = parseInt(query.page) || 1;
const limit = Math.min(parseInt(query.limit) || 20, 200);
const skip = (page - 1) * limit;
const filter = {};
if (query.search) {
filter.$or = [
{ name: new RegExp(query.search, 'i') },
{ surname: new RegExp(query.search, 'i') },
{ username: new RegExp(query.search, 'i') }
];
}
if (query.isActive !== undefined) filter.isActive = query.isActive === 'true';
const [items, total] = await Promise.all([
User.find(filter).select(SAFE_FIELDS).populate(POPULATE_ROLE).skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
User.countDocuments(filter)
]);
return { data: items, meta: calculateMeta(total, page, limit) };
};
const searchUsers = async (query) => getAllUsers(query);
const createUserAdmin = async (body) => {
const {
name,
surname,
nationalIdCode,
nationalId,
phoneNumber,
phone,
username: requestedUsername,
password: requestedPassword,
email,
roleId,
role,
address,
preferredMessenger,
} = body;
let roleObj = null;
if (roleId || role) {
roleObj = await Role.findById(roleId || role);
}
if (!roleObj) {
roleObj = await Role.findOne({ name: 'User' });
}
if (!roleObj) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
const plainPassword = (requestedPassword && String(requestedPassword).trim()) || generateSimplePassword();
const username = await allocateUniqueUsername(requestedUsername);
const passwordHash = await bcrypt.hash(plainPassword, 10);
const resolvedPhone = phoneNumber || phone;
const user = await User.create({
name,
surname,
nationalIdCode: nationalIdCode || nationalId,
phoneNumber: resolvedPhone,
username,
passwordHash,
email,
address,
preferredMessenger: normalizePreferredMessengers(preferredMessenger),
role: roleObj._id,
});
try {
await sendAccountCreatedSms(resolvedPhone, username, plainPassword);
} catch (err) {
logger.error(`[createUserAdmin] Account SMS failed for ${resolvedPhone}: ${err.message}`);
}
const created = await User.findById(user._id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean();
return {
...created,
generatedCredentials: {
username,
password: plainPassword,
},
};
};
const updateUser = async (id, body) => {
const {
password,
nationalId,
nationalIdCode,
phone,
phoneNumber,
roleId,
role,
username,
passwordHash,
refreshTokens,
preferredMessenger,
_id,
id: bodyId,
createdAt,
updatedAt,
__v,
...rest
} = body;
const update = { ...rest };
// Map frontend field aliases to schema fields
if (nationalIdCode || nationalId) {
update.nationalIdCode = nationalIdCode || nationalId;
}
if (phoneNumber || phone) {
update.phoneNumber = phoneNumber || phone;
}
if (roleId || role) {
update.role = roleId || role;
}
if (preferredMessenger !== undefined) {
update.preferredMessenger = normalizePreferredMessengers(preferredMessenger) || [];
}
// Never overwrite username with an empty value on update
if (typeof username === 'string' && username.trim()) {
update.username = username.trim();
}
// Strip empty strings so required validators are not tripped
Object.keys(update).forEach((key) => {
if (key === 'preferredMessenger') return;
if (update[key] === '' || update[key] === null || update[key] === undefined) {
delete update[key];
}
});
if (password) {
update.passwordHash = await bcrypt.hash(password, 10);
}
const user = await User.findByIdAndUpdate(id, update, { new: true, runValidators: true })
.select(SAFE_FIELDS)
.populate(POPULATE_ROLE)
.lean();
if (!user) throw new AppError('USER_NOT_FOUND');
return user;
};
const deleteUser = async (id) => {
const user = await User.findByIdAndDelete(id);
if (!user) throw new AppError('USER_NOT_FOUND');
};
const enrollUserInCourse = async (userId, courseId, actorId) => {
const user = await User.findById(userId);
if (!user) throw new AppError('USER_NOT_FOUND');
if (!user.courses.includes(courseId)) {
user.courses.push(courseId);
await user.save();
}
return User.findById(userId).select(SAFE_FIELDS).populate('courses').lean();
};
module.exports = { signUp, getUserById, getAllUsers, searchUsers, createUserAdmin, updateUser, deleteUser, enrollUserInCourse };
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});