Initial commit: teaching institution management API.
Express/MongoDB backend with JWT auth, RBAC, S3 uploads, notifications, and scheduled jobs.
This commit is contained in:
@@ -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
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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,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
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,6 @@
|
||||
// Stub validator — pass-through middleware (no validation yet)
|
||||
const passThrough = (req, res, next) => next();
|
||||
|
||||
module.exports = new Proxy({}, {
|
||||
get: () => passThrough
|
||||
});
|
||||
Reference in New Issue
Block a user