Initial commit: teaching institution management API.

Express/MongoDB backend with JWT auth, RBAC, S3 uploads, notifications, and scheduled jobs.
This commit is contained in:
2026-08-09 04:18:08 +02:00
commit f04c797be6
107 changed files with 9190 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
npm-debug.log
.git
.env
+55
View File
@@ -0,0 +1,55 @@
# Environment Configuration
NODE_ENV=development
PORT=3000
MONGO_URI=mongodb://localhost:27017/teaching_institution_db
# Authentication JWT
JWT_ACCESS_SECRET=your_super_secret_access_token_key_change_me
JWT_REFRESH_SECRET=your_super_secret_refresh_token_key_change_me
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Localization & File Uploads
DEFAULT_LANG=fa
CORS_ORIGIN=*
UPLOAD_PATH=uploads
# S3 / MinIO Private Storage Settings
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_TEMP_BUCKET=gameno-temp
S3_STORAGE_BUCKET=gameno-storage
S3_FORCE_PATH_STYLE=true
SIGNED_URL_EXPIRES_IN=900
# SMTP Email Configuration
SMTP_HOST=smtp.mailtrap.io
SMTP_PORT=2525
SMTP_USER=your_smtp_user
SMTP_PASS=your_smtp_password
EMAIL_FROM=no-reply@institution.com
# SMS Provider Configuration (sms.ir)
SMS_ENABLED=false
SMS_PANEL_TOKEN=your_sms_ir_api_key
SMS_SENDER_NUMBER=10001000
# Template IDs from sms.ir panel (verify templates)
# Account created vars: username, password
SMS_TEMPLATE_ACCOUNT_CREATED=
# Class registered vars: className
SMS_TEMPLATE_CLASS_REGISTERED=
# Class reminder (30 min before) vars: className, time, place
SMS_TEMPLATE_CLASS_REMINDER=
# Bale Messenger Bot Token
BALE_BOT_TOKEN=mock_bale_bot_token
# SuperAdmin Seed Credentials
SUPERADMIN_USERNAME=superadmin
SUPERADMIN_PASSWORD=SuperAdminSecret123!
SUPERADMIN_EMAIL=admin@institution.com
SUPERADMIN_NATIONAL_ID=0000000000
SUPERADMIN_PHONE=09000000000
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.env
.DS_Store
+13
View File
@@ -0,0 +1,13 @@
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
+115
View File
@@ -0,0 +1,115 @@
# Teaching Institution Management Dashboard API
Production-ready Express.js & MongoDB backend API for an internal management dashboard of a teaching institution. Built with a strict layered architecture, JWT authentication with refresh token rotation, Joi validation, native Node.js EventEmitter, dynamic localization (English & Persian), background jobs, multi-channel notifications (Email, SMS, Bale Bot), custom Role-Based Access Control (RBAC), and private AWS S3 / MinIO object storage with presigned temporary URLs.
---
## Technical Features & Domain Architecture
- **Private S3 / MinIO Storage System**:
- Bucket Privacy: All buckets are strictly private; no public static file URLs are exposed.
- **Presigned URLs**: Dynamic presigned temporary URLs generated via `@aws-sdk/s3-request-presigner` (`getSignedUrl`).
- **Two-Stage Temp Upload Workflow**:
1. **Upload**: User/Admin uploads file to `POST /files/user/upload-temp` (or `/files/admin/upload-temp`), streaming directly to `S3_TEMP_BUCKET`. Returns `tempFileName`.
2. **Commit**: When submitting changes (e.g. creating/updating Certificate), the server copies the file from `S3_TEMP_BUCKET` to `S3_STORAGE_BUCKET` and removes the temp file.
- **Daily Temp Bucket Cleanup**: Scheduled cron job running daily at 3:00 AM (`tempBucketCleanupJob`), deleting files in `S3_TEMP_BUCKET` older than 10 minutes.
- **Courses & Classes**:
- Each **Course** can contain multiple **Class** sections (e.g. "AutoDesk Farvardin A", "AutoDesk Farvardin B").
- Each **Class** maintains a `registeredUsers` array.
- **Sessions & Attendance**:
- Embedded `attendanceList` array inside each Session model.
- **Strict Layered Architecture**: `Router``Controller``Service``Model` per component.
---
## Endpoints Overview
| Component | Scope | Verb | Route | Description | Permission |
| :--- | :--- | :--- | :--- | :--- | :--- |
| **Auth** | Public | `POST` | `/auth/login` | Login with username & password | Public |
| | Public | `POST` | `/auth/refresh` | Refresh access & refresh tokens | Public |
| | Auth | `POST` | `/auth/logout` | Revoke refresh token | Authenticated |
| **Files** | User | `POST` | `/files/user/upload-temp` | Upload file to S3 temp bucket | `files:upload` |
| | User | `GET` | `/files/user/signed-url/:filename` | Get presigned temporary GET URL | `files:read` |
| | Admin | `POST` | `/files/admin/upload-temp` | Admin upload file to S3 temp bucket | `files:upload` |
| | Admin | `GET` | `/files/admin/signed-url/:filename` | Admin get presigned GET URL | `files:read` |
| | Admin | `DELETE` | `/files/admin/delete/:filename` | Delete file from bucket | `files:delete` |
| **Users** | User | `POST` | `/users/user/sign-up` | Self-register as a student | Public |
| | User | `GET` | `/users/user/get-self` | Get self profile | Authenticated |
| | User | `PUT` | `/users/user/update-self` | Update self profile | Authenticated |
| | Admin | `POST` | `/users/admin/create` | Create user with specific role | `users:create` |
| | Admin | `GET` | `/users/admin/get-all` | Get paginated users | `users:read` |
| | Admin | `GET` | `/users/admin/search` | Search users | `users:search` |
| | Admin | `GET` | `/users/admin/get-one/:id` | Get single user by ID | `users:read` |
| | Admin | `PUT` | `/users/admin/update/:id` | Update user details | `users:update` |
| | Admin | `DELETE` | `/users/admin/delete/:id` | Delete user | `users:delete` |
| | Admin | `POST` | `/users/admin/:userId/enroll` | Enroll student into course | `users:enroll` |
| **Professors** | Admin | `POST` | `/professors/admin/create` | Create professor | `professors:create` |
| | Admin | `GET` | `/professors/admin/get-all` | List professors | `professors:read` |
| | Admin | `GET` | `/professors/admin/search` | Search professors | `professors:search` |
| | Admin | `GET` | `/professors/admin/get-one/:id` | Get professor details | `professors:read` |
| | Admin | `PUT` | `/professors/admin/update/:id` | Update professor | `professors:update` |
| | Admin | `DELETE` | `/professors/admin/delete/:id` | Delete professor | `professors:delete` |
| **Courses** | User | `GET` | `/courses/user/get-all` | List public courses | Public / Auth |
| | User | `GET` | `/courses/user/get-one/:id` | Get public course details | Public / Auth |
| | Admin | `POST` | `/courses/admin/create` | Create new course | `courses:create` |
| | Admin | `GET` | `/courses/admin/get-all` | List all courses | `courses:read` |
| | Admin | `GET` | `/courses/admin/search` | Search courses | `courses:search` |
| | Admin | `GET` | `/courses/admin/get-one/:id` | Get course by ID | `courses:read` |
| | Admin | `PUT` | `/courses/admin/update/:id` | Update course details | `courses:update` |
| | Admin | `DELETE` | `/courses/admin/delete/:id` | Delete course | `courses:delete` |
| **Classes** | User | `GET` | `/classes/user/my-classes` | View enrolled student classes | Authenticated |
| | Admin | `POST` | `/classes/admin/create` | Create class section | `classes:create` |
| | Admin | `GET` | `/classes/admin/get-all` | List class sections | `classes:read` |
| | Admin | `GET` | `/classes/admin/search` | Search class sections | `classes:search` |
| | Admin | `GET` | `/classes/admin/get-one/:id` | Get class details | `classes:read` |
| | Admin | `PUT` | `/classes/admin/update/:id` | Update class details | `classes:update` |
| | Admin | `DELETE` | `/classes/admin/delete/:id` | Delete class section | `classes:delete` |
| | Admin | `PUT` | `/classes/admin/:id/registered-users` | Edit registered users list | `classes:register_users` |
| **Sessions** | User | `GET` | `/sessions/user/my-sessions` | Enrolled student sessions | Authenticated |
| | Admin | `POST` | `/sessions/admin/create` | Schedule session | `sessions:create` |
| | Admin | `GET` | `/sessions/admin/get-all` | List sessions | `sessions:read` |
| | Admin | `GET` | `/sessions/admin/search` | Search sessions | `sessions:search` |
| | Admin | `GET` | `/sessions/admin/get-one/:id` | Get session details | `sessions:read` |
| | Admin | `PUT` | `/sessions/admin/update/:id` | Update/Cancel session | `sessions:update` |
| | Admin | `DELETE` | `/sessions/admin/delete/:id` | Delete session | `sessions:delete` |
| | Admin | `PUT` | `/sessions/admin/:id/attendance` | Record session attendance list | `sessions:attendance` |
| **Payments** | User | `GET` | `/payments/user/my-payments` | View user payments | Authenticated |
| | User | `POST` | `/payments/user/pay/:id` | Add payment transaction | Authenticated |
| | Admin | `POST` | `/payments/admin/create` | Create payment ledger | `payments:create` |
| | Admin | `GET` | `/payments/admin/get-all` | List payments | `payments:read` |
| | Admin | `GET` | `/payments/admin/search` | Search payments | `payments:search` |
| | Admin | `GET` | `/payments/admin/get-one/:id` | Get payment details | `payments:read` |
| | Admin | `PUT` | `/payments/admin/update/:id` | Update payment details | `payments:update` |
| | Admin | `DELETE` | `/payments/admin/delete/:id` | Delete payment ledger | `payments:delete` |
| **Roles** | Admin | `POST` | `/roles/admin/create` | Create custom role | `roles:create` |
| | Admin | `GET` | `/roles/admin/get-all` | List custom roles | `roles:read` |
| | Admin | `GET` | `/roles/admin/search` | Search roles | `roles:search` |
| | Admin | `GET` | `/roles/admin/get-one/:id` | Get role by ID | `roles:read` |
| | Admin | `PUT` | `/roles/admin/update/:id` | Update role permissions | `roles:update` |
| | Admin | `DELETE` | `/roles/admin/delete/:id` | Delete non-system role | `roles:delete` |
| **Notifications**| User | `GET` | `/notifications/user/my-notifications` | View received notifications | Authenticated |
| | Admin | `POST` | `/notifications/admin/create` | Send notification | `notifications:create` |
| | Admin | `GET` | `/notifications/admin/get-all` | List all notifications | `notifications:read` |
| | Admin | `GET` | `/notifications/admin/search` | Search notifications | `notifications:search` |
| | Admin | `GET` | `/notifications/admin/get-one/:id` | Get notification by ID | `notifications:read` |
| | Admin | `PUT` | `/notifications/admin/update/:id` | Update notification | `notifications:update` |
| | Admin | `DELETE` | `/notifications/admin/delete/:id` | Delete notification | `notifications:delete` |
| | Admin | `POST` | `/notifications/admin/:id/retry` | Manually retry failed dispatch | `notifications:retry` |
| **Certificates** | User | `GET` | `/certificates/user/my-certificates` | View personal certificates | `certificates:read` |
| | User | `POST` | `/certificates/user/upload` | Upload & commit certificate | `files:upload` |
| | Admin | `POST` | `/certificates/admin/create` | Issue official certificate | `certificates:create` |
| | Admin | `GET` | `/certificates/admin/get-all` | List all certificates | `certificates:read` |
| | Admin | `GET` | `/certificates/admin/search` | Search certificates | `certificates:search` |
| | Admin | `GET` | `/certificates/admin/get-one/:id` | Get certificate details | `certificates:read` |
| | Admin | `PUT` | `/certificates/admin/update/:id` | Update certificate | `certificates:update` |
| | Admin | `DELETE` | `/certificates/admin/delete/:id` | Delete certificate | `certificates:delete` |
---
## Verification
Check code syntax:
```bash
node --check app.js && node --check seed.js
```
+111
View File
@@ -0,0 +1,111 @@
// /app.js — Main Express Application Entry Point
'use strict';
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const config = require('./config/config');
const connectDB = require('./config/db');
const logger = require('./utils/logger');
const notFoundHandler = require('./middlewares/notFoundHandler');
const globalErrorHandler = require('./middlewares/globalErrorHandler');
// ── Route imports ─────────────────────────────────────────────────────────────
const authRoutes = require('./components/auth/authRoutes');
const userRoutes = require('./components/users/userRoutes');
const professorRoutes = require('./components/professors/professorRoutes');
const courseRoutes = require('./components/courses/courseRoutes');
const classRoutes = require('./components/classes/classRoutes');
const sessionRoutes = require('./components/sessions/sessionRoutes');
const paymentRoutes = require('./components/payments/paymentRoutes');
const roleRoutes = require('./components/roles/roleRoutes');
const notificationRoutes = require('./components/notifications/notificationRoutes');
const certificateRoutes = require('./components/certificates/certificateRoutes');
const fileRoutes = require('./components/files/fileRoutes');
const dashboardRoutes = require('./components/dashboard/dashboardRoutes');
const activityLogRoutes = require('./components/activityLogs/activityLogRoutes');
const contactInquiryRoutes = require('./components/contactInquiries/contactInquiryRoutes');
const activityLogger = require('./middlewares/activityLogger');
const app = express();
// ── Security & Middleware ─────────────────────────────────────────────────────
app.use(helmet());
app.use(cors({
origin: config.CORS_ORIGIN,
credentials: true
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(activityLogger);
// Global rate limiter
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 300,
standardHeaders: true,
legacyHeaders: false,
message: { status: 'error', message: 'Too many requests. Please try again later.' }
});
app.use('/api', limiter);
// ── Routes ────────────────────────────────────────────────────────────────────
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
message: 'Gameno API is running',
timestamp: new Date().toISOString(),
environment: config.NODE_ENV
});
});
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/professors', professorRoutes);
app.use('/api/courses', courseRoutes);
app.use('/api/classes', classRoutes);
app.use('/api/sessions', sessionRoutes);
app.use('/api/payments', paymentRoutes);
app.use('/api/roles', roleRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/certificates', certificateRoutes);
app.use('/api/files', fileRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/activity-logs', activityLogRoutes);
app.use('/api/contact-inquiries', contactInquiryRoutes);
// ── Error Handlers ────────────────────────────────────────────────────────────
app.use(notFoundHandler);
app.use(globalErrorHandler);
// ── Server startup ────────────────────────────────────────────────────────────
const PORT = config.PORT || 3000;
const startServer = async () => {
await connectDB();
const registerEventListeners = require('./events/eventListeners');
const { startPaymentReminderJob } = require('./jobs/paymentReminderJob');
const { startNotificationRetryJob } = require('./jobs/notificationRetryJob');
const { startTempBucketCleanupJob } = require('./jobs/tempBucketCleanupJob');
const { startClassReminderJob } = require('./jobs/classReminderJob');
registerEventListeners();
startPaymentReminderJob();
startNotificationRetryJob();
startTempBucketCleanupJob();
startClassReminderJob();
app.listen(PORT, () => {
logger.info(`Gameno API listening on http://localhost:${PORT} [${config.NODE_ENV}]`);
});
};
startServer().catch(err => {
logger.error('Failed to start server:', err);
process.exit(1);
});
module.exports = app;
@@ -0,0 +1,16 @@
// /components/activityLogs/activityLogController.js
'use strict';
const catchAsync = require('../../utils/catchAsync');
const activityLogService = require('./activityLogService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await activityLogService.getAllActivityLogs(req.query);
return listResponse(res, 200, data, meta);
});
exports.getActions = catchAsync(async (req, res) => {
const actions = activityLogService.getActivityActions();
return successResponse(res, 200, 'Activity actions retrieved successfully', actions);
});
@@ -0,0 +1,87 @@
// /components/activityLogs/activityLogModel.js
'use strict';
const mongoose = require('mongoose');
const ACTIVITY_ACTIONS = [
'create',
'update',
'delete',
'login',
'logout',
'enroll',
'attendance',
'upload',
'retry',
'other'
];
const activityLogSchema = new mongoose.Schema(
{
actor: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
default: null,
index: true
},
actorUsername: {
type: String,
default: null,
index: true
},
actorName: {
type: String,
default: null
},
action: {
type: String,
enum: ACTIVITY_ACTIONS,
required: true,
index: true
},
resource: {
type: String,
required: true,
index: true
},
resourceId: {
type: String,
default: null
},
method: {
type: String,
required: true
},
path: {
type: String,
required: true
},
statusCode: {
type: Number,
default: null
},
ip: {
type: String,
default: null
},
userAgent: {
type: String,
default: null
},
description: {
type: String,
default: ''
},
metadata: {
type: mongoose.Schema.Types.Mixed,
default: {}
}
},
{ timestamps: true }
);
activityLogSchema.index({ createdAt: -1 });
activityLogSchema.index({ action: 1, createdAt: -1 });
module.exports = mongoose.model('ActivityLog', activityLogSchema);
module.exports.ACTIVITY_ACTIONS = ACTIVITY_ACTIONS;
@@ -0,0 +1,26 @@
// /components/activityLogs/activityLogRoutes.js
'use strict';
const express = require('express');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const activityLogController = require('./activityLogController');
const router = express.Router();
router.get(
'/admin/get-all',
authMiddleware,
perm.requires(PERMISSIONS.LOGS_READ),
activityLogController.getAll
);
router.get(
'/admin/actions',
authMiddleware,
perm.requires(PERMISSIONS.LOGS_READ),
activityLogController.getActions
);
module.exports = router;
@@ -0,0 +1,146 @@
// /components/activityLogs/activityLogService.js
'use strict';
const ActivityLog = require('./activityLogModel');
const { ACTIVITY_ACTIONS } = require('./activityLogModel');
const { parsePaginationAndSort, calculateMeta } = require('../../utils/pagination');
const SENSITIVE_KEYS = new Set([
'password',
'passwordHash',
'refreshToken',
'accessToken',
'token',
'secret',
'smtp_pass',
'SMTP_PASS'
]);
const sanitizeValue = (value, depth = 0) => {
if (value == null) return value;
if (depth > 3) return '[truncated]';
if (Array.isArray(value)) {
return value.slice(0, 20).map((item) => sanitizeValue(item, depth + 1));
}
if (typeof value === 'object') {
const cleaned = {};
Object.keys(value).slice(0, 30).forEach((key) => {
if (SENSITIVE_KEYS.has(key)) {
cleaned[key] = '[redacted]';
} else {
cleaned[key] = sanitizeValue(value[key], depth + 1);
}
});
return cleaned;
}
if (typeof value === 'string' && value.length > 300) {
return `${value.slice(0, 300)}`;
}
return value;
};
const buildDescription = ({ action, resource, actorName, actorUsername, method, path }) => {
const ACTION_LABELS = {
create: 'ایجاد',
update: 'ویرایش',
delete: 'حذف',
login: 'ورود',
logout: 'خروج',
enroll: 'ثبت‌نام',
attendance: 'حضور و غیاب',
upload: 'آپلود',
retry: 'تلاش مجدد',
other: 'عملیات'
};
const RESOURCE_LABELS = {
auth: 'احراز هویت',
users: 'کاربران',
professors: 'اساتید',
courses: 'دوره‌ها',
classes: 'کلاس‌ها',
sessions: 'جلسات',
payments: 'پرداخت‌ها',
roles: 'نقش‌ها',
notifications: 'اطلاعیه‌ها',
certificates: 'گواهینامه‌ها',
files: 'فایل‌ها',
dashboard: 'داشبورد',
'contact-inquiries': 'درخواست‌های تماس',
'activity-logs': 'گزارش فعالیت‌ها'
};
const who = actorName || actorUsername || 'سیستم';
const actionLabel = ACTION_LABELS[action] || action || 'عملیات';
const resourceLabel = RESOURCE_LABELS[resource] || resource || 'منبع';
return `${who}${actionLabel} روی ${resourceLabel} (${method} ${path})`;
};
const createActivityLog = async (payload) => {
const doc = {
...payload,
metadata: sanitizeValue(payload.metadata || {}),
description:
payload.description ||
buildDescription(payload)
};
return ActivityLog.create(doc);
};
const getAllActivityLogs = async (queryParams = {}) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = {};
if (queryParams.action) {
filter.action = queryParams.action;
}
if (queryParams.resource) {
filter.resource = queryParams.resource;
}
if (queryParams.actor) {
filter.actor = queryParams.actor;
}
if (queryParams.q) {
const searchRegex = new RegExp(queryParams.q, 'i');
filter.$or = [
{ description: searchRegex },
{ actorUsername: searchRegex },
{ actorName: searchRegex },
{ path: searchRegex },
{ resource: searchRegex },
{ resourceId: searchRegex }
];
}
const [logs, totalCount] = await Promise.all([
ActivityLog.find(filter)
.populate('actor', 'name surname username')
.sort(sort)
.skip(skip)
.limit(limit)
.lean(),
ActivityLog.countDocuments(filter)
]);
return {
data: logs,
meta: calculateMeta(totalCount, page, limit)
};
};
const getActivityActions = () => ACTIVITY_ACTIONS;
module.exports = {
createActivityLog,
getAllActivityLogs,
getActivityActions,
sanitizeValue,
buildDescription
};
+24
View File
@@ -0,0 +1,24 @@
// /components/auth/authController.js
const catchAsync = require('../../utils/catchAsync');
const authService = require('./authService');
const { successResponse } = require('../../utils/apiResponse');
exports.login = catchAsync(async (req, res, next) => {
const { username, password } = req.body;
const result = await authService.login(username, password);
return successResponse(res, 200, 'Login successful', result);
});
exports.refresh = catchAsync(async (req, res, next) => {
const { refreshToken } = req.body;
const result = await authService.refreshToken(refreshToken);
return successResponse(res, 200, 'Tokens refreshed successfully', result);
});
exports.logout = catchAsync(async (req, res, next) => {
const { refreshToken } = req.body;
const userId = req.user?._id;
await authService.logout(userId, refreshToken);
return successResponse(res, 200, 'Logout successful');
});
+14
View File
@@ -0,0 +1,14 @@
// /components/auth/authRoutes.js
const express = require('express');
const authController = require('./authController');
const { validateLogin, validateRefresh } = require('./authValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const router = express.Router();
router.post('/login', validateLogin, authController.login);
router.post('/refresh', validateRefresh, authController.refresh);
router.post('/logout', authMiddleware, authController.logout);
module.exports = router;
+110
View File
@@ -0,0 +1,110 @@
// /components/auth/authService.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const User = require('../users/userModel');
const config = require('../../config/config');
const AppError = require('../../utils/AppError');
const generateTokens = (user) => {
const payload = {
id: user._id,
username: user.username,
role: user.role?._id || user.role
};
const accessToken = jwt.sign(payload, config.JWT_ACCESS_SECRET, {
expiresIn: config.JWT_ACCESS_EXPIRES_IN
});
const refreshToken = jwt.sign({ id: user._id }, config.JWT_REFRESH_SECRET, {
expiresIn: config.JWT_REFRESH_EXPIRES_IN
});
return { accessToken, refreshToken };
};
const login = async (username, password) => {
const user = await User.findOne({ username }).populate('role');
if (!user || !user.isActive) {
throw new AppError('INVALID_CREDENTIALS');
}
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
throw new AppError('INVALID_CREDENTIALS');
}
const { accessToken, refreshToken: refreshTokenString } = generateTokens(user);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
user.refreshTokens.push({ token: refreshTokenString, expiresAt });
await user.save();
const userObject = user.toObject();
delete userObject.passwordHash;
delete userObject.refreshTokens;
return {
user: userObject,
accessToken,
refreshToken: refreshTokenString
};
};
const refreshToken = async (refreshTokenString) => {
let decoded;
try {
decoded = jwt.verify(refreshTokenString, config.JWT_REFRESH_SECRET);
} catch (err) {
throw new AppError('INVALID_REFRESH_TOKEN');
}
const user = await User.findById(decoded.id).populate('role');
if (!user || !user.isActive) {
throw new AppError('INVALID_REFRESH_TOKEN');
}
const tokenIndex = user.refreshTokens.findIndex(rt => rt.token === refreshTokenString);
if (tokenIndex === -1) {
throw new AppError('INVALID_REFRESH_TOKEN');
}
if (new Date() > new Date(user.refreshTokens[tokenIndex].expiresAt)) {
user.refreshTokens.splice(tokenIndex, 1);
await user.save();
throw new AppError('TOKEN_EXPIRED');
}
user.refreshTokens.splice(tokenIndex, 1);
const { accessToken, refreshToken: newRefreshToken } = generateTokens(user);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
user.refreshTokens.push({ token: newRefreshToken, expiresAt });
await user.save();
return {
accessToken,
refreshToken: newRefreshToken
};
};
const logout = async (userId, refreshTokenString) => {
const user = await User.findById(userId);
if (user) {
user.refreshTokens = user.refreshTokens.filter(rt => rt.token !== refreshTokenString);
await user.save();
}
return true;
};
module.exports = {
login,
refreshToken,
logout
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
@@ -0,0 +1,45 @@
// /components/certificates/certificateController.js
const catchAsync = require('../../utils/catchAsync');
const certificateService = require('./certificateService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res, next) => {
const certificate = await certificateService.createCertificate(req.body);
return successResponse(res, 201, 'Certificate created successfully', certificate);
});
exports.getOne = catchAsync(async (req, res, next) => {
const certificate = await certificateService.getCertificateById(req.params.id);
return successResponse(res, 200, 'Certificate retrieved successfully', certificate);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await certificateService.getAllCertificates(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const certificate = await certificateService.updateCertificate(req.params.id, req.body);
return successResponse(res, 200, 'Certificate updated successfully', certificate);
});
exports.delete = catchAsync(async (req, res, next) => {
await certificateService.deleteCertificate(req.params.id);
return successResponse(res, 200, 'Certificate deleted successfully');
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await certificateService.searchCertificates(req.query);
return listResponse(res, 200, data, meta);
});
exports.upload = catchAsync(async (req, res, next) => {
const certificate = await certificateService.uploadUserCertificate(req.user._id, req.body);
return successResponse(res, 201, 'Certificate uploaded and committed successfully', certificate);
});
exports.getMyCertificates = catchAsync(async (req, res, next) => {
const { data, meta } = await certificateService.getMyCertificates(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
@@ -0,0 +1,47 @@
// /components/certificates/certificateModel.js
const mongoose = require('mongoose');
const certificateSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course',
index: true
},
title: {
type: String,
required: true,
trim: true
},
issuer: {
type: String,
trim: true
},
fileKey: {
type: String,
required: true,
trim: true
},
originalName: {
type: String,
trim: true
},
issuedAt: {
type: Date,
default: Date.now
},
isOfficial: {
type: Boolean,
default: false
}
}, {
timestamps: true
});
module.exports = mongoose.model('Certificate', certificateSchema);
@@ -0,0 +1,30 @@
// /components/certificates/certificateRoutes.js
const express = require('express');
const certificateController = require('./certificateController');
const {
validateCreateCertificate,
validateUpdateCertificate,
validateUploadCertificate
} = require('./certificateValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
// User Scope
router.get('/user/my-certificates', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getMyCertificates);
router.post('/user/upload', perm.requires(PERMISSIONS.FILES_UPLOAD), validateUploadCertificate, certificateController.upload);
// Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.CERTIFICATES_CREATE), validateCreateCertificate, certificateController.create);
router.get('/admin/get-all', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.CERTIFICATES_SEARCH), certificateController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.CERTIFICATES_READ), certificateController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.CERTIFICATES_UPDATE), validateUpdateCertificate, certificateController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.CERTIFICATES_DELETE), certificateController.delete);
module.exports = router;
@@ -0,0 +1,181 @@
// /components/certificates/certificateService.js
const path = require('path');
const Certificate = require('./certificateModel');
const User = require('../users/userModel');
const AppError = require('../../utils/AppError');
const { commitTempFile, generatePresignedUrl, deleteFromBucket } = require('../../utils/s3Client');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const createCertificate = async (data) => {
const user = await User.findById(data.user);
if (!user) throw new AppError('USER_NOT_FOUND');
// Move file from Temp bucket to Permanent Storage bucket
const targetKey = `certificates/cert-${Date.now()}-${path.basename(data.tempFileName)}`;
const { fileKey } = await commitTempFile(data.tempFileName, targetKey);
const certificate = await Certificate.create({
user: data.user,
course: data.course || null,
title: data.title,
issuer: data.issuer || '',
fileKey,
originalName: data.originalName || path.basename(data.tempFileName),
isOfficial: data.isOfficial || false
});
user.certificates.push(certificate._id);
await user.save();
eventEmitter.emit(EVENT_NAMES.CERTIFICATE_ISSUED, { certificateId: certificate._id, userId: user._id });
const resultObj = certificate.toObject();
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
return resultObj;
};
const getCertificateById = async (id) => {
const certificate = await Certificate.findById(id)
.populate('user', 'name surname username nationalIdCode')
.populate('course', 'title type');
if (!certificate) {
throw new AppError('CERTIFICATE_NOT_FOUND');
}
const resultObj = certificate.toObject();
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
return resultObj;
};
const getAllCertificates = async (queryParams) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['title', 'issuer']);
const [certificates, totalCount] = await Promise.all([
Certificate.find(filter)
.populate('user', 'name surname username')
.populate('course', 'title')
.sort(sort)
.skip(skip)
.limit(limit),
Certificate.countDocuments(filter)
]);
const listWithUrls = await Promise.all(
certificates.map(async (cert) => {
const item = cert.toObject();
item.presignedUrl = await generatePresignedUrl(cert.fileKey);
return item;
})
);
const meta = calculateMeta(totalCount, page, limit);
return { data: listWithUrls, meta };
};
const updateCertificate = async (id, updateData) => {
const certificate = await Certificate.findById(id);
if (!certificate) {
throw new AppError('CERTIFICATE_NOT_FOUND');
}
if (updateData.tempFileName) {
const targetKey = `certificates/cert-${Date.now()}-${path.basename(updateData.tempFileName)}`;
const { fileKey } = await commitTempFile(updateData.tempFileName, targetKey);
// Delete old file from storage bucket
await deleteFromBucket(certificate.fileKey);
certificate.fileKey = fileKey;
}
if (updateData.title) certificate.title = updateData.title;
if (updateData.issuer !== undefined) certificate.issuer = updateData.issuer;
if (updateData.isOfficial !== undefined) certificate.isOfficial = updateData.isOfficial;
await certificate.save();
const resultObj = certificate.toObject();
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
return resultObj;
};
const deleteCertificate = async (id) => {
const certificate = await Certificate.findById(id);
if (!certificate) {
throw new AppError('CERTIFICATE_NOT_FOUND');
}
await deleteFromBucket(certificate.fileKey);
await User.findByIdAndUpdate(certificate.user, { $pull: { certificates: certificate._id } });
await Certificate.findByIdAndDelete(id);
return null;
};
const searchCertificates = async (queryParams) => {
return getAllCertificates(queryParams);
};
const uploadUserCertificate = async (userId, data) => {
const user = await User.findById(userId);
if (!user) throw new AppError('USER_NOT_FOUND');
const targetKey = `certificates/cert-${Date.now()}-${path.basename(data.tempFileName)}`;
const { fileKey } = await commitTempFile(data.tempFileName, targetKey);
const certificate = await Certificate.create({
user: userId,
course: data.course || null,
title: data.title,
issuer: data.issuer || 'Self-Uploaded',
fileKey,
originalName: data.originalName || path.basename(data.tempFileName),
isOfficial: false
});
user.certificates.push(certificate._id);
await user.save();
eventEmitter.emit(EVENT_NAMES.CERTIFICATE_UPLOADED, { certificateId: certificate._id, userId });
const resultObj = certificate.toObject();
resultObj.presignedUrl = await generatePresignedUrl(certificate.fileKey);
return resultObj;
};
const getMyCertificates = async (userId, queryParams) => {
const filter = { user: userId, ...buildFilterQuery(queryParams) };
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const [certificates, totalCount] = await Promise.all([
Certificate.find(filter)
.populate('course', 'title type')
.sort(sort)
.skip(skip)
.limit(limit),
Certificate.countDocuments(filter)
]);
const listWithUrls = await Promise.all(
certificates.map(async (cert) => {
const item = cert.toObject();
item.presignedUrl = await generatePresignedUrl(cert.fileKey);
return item;
})
);
const meta = calculateMeta(totalCount, page, limit);
return { data: listWithUrls, meta };
};
module.exports = {
createCertificate,
getCertificateById,
getAllCertificates,
updateCertificate,
deleteCertificate,
searchCertificates,
uploadUserCertificate,
getMyCertificates
};
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+41
View File
@@ -0,0 +1,41 @@
// /components/classes/classController.js
'use strict';
const catchAsync = require('../../utils/catchAsync');
const classService = require('./classService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await classService.getAll(req.query);
return listResponse(res, 200, data, meta);
});
exports.getOne = catchAsync(async (req, res) => {
const cls = await classService.getOne(req.params.id);
return successResponse(res, 200, 'Class retrieved successfully', cls);
});
exports.create = catchAsync(async (req, res) => {
const cls = await classService.create(req.body);
return successResponse(res, 201, 'Class created successfully', cls);
});
exports.update = catchAsync(async (req, res) => {
const cls = await classService.update(req.params.id, req.body);
return successResponse(res, 200, 'Class updated successfully', cls);
});
exports.delete = catchAsync(async (req, res) => {
await classService.remove(req.params.id);
return successResponse(res, 200, 'Class deleted successfully');
});
exports.registerUsers = catchAsync(async (req, res) => {
const cls = await classService.registerUsers(req.params.id, req.body.userIds || []);
return successResponse(res, 200, 'Users registered in class successfully', cls);
});
exports.getMyClasses = catchAsync(async (req, res) => {
const { data, meta } = await classService.getMyClasses(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
+47
View File
@@ -0,0 +1,47 @@
// /components/classes/classModel.js
'use strict';
const mongoose = require('mongoose');
const classSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true
},
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course',
required: true
},
professor: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Professor'
},
students: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}],
capacity: {
type: Number,
default: 30
},
tuitionFee: {
type: Number,
default: 0
},
startDate: {
type: Date
},
endDate: {
type: Date
},
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true
});
module.exports = mongoose.model('Class', classSchema);
+25
View File
@@ -0,0 +1,25 @@
// /components/classes/classRoutes.js
'use strict';
const express = require('express');
const classController = require('./classController');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
// User Scope
router.get('/user/my-classes', perm.requires(PERMISSIONS.CLASSES_READ), classController.getMyClasses);
// Admin Scope
router.get('/admin/get-all', perm.requires(PERMISSIONS.CLASSES_READ), classController.getAll);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.CLASSES_READ), classController.getOne);
router.post('/admin/create', perm.requires(PERMISSIONS.CLASSES_CREATE), classController.create);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.CLASSES_UPDATE), classController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.CLASSES_DELETE), classController.delete);
router.post('/admin/:id/register-users', perm.requires(PERMISSIONS.CLASSES_REGISTER_USERS), classController.registerUsers);
module.exports = router;
+108
View File
@@ -0,0 +1,108 @@
// /components/classes/classService.js
'use strict';
const Class = require('./classModel');
const User = require('../users/userModel');
const AppError = require('../../utils/AppError');
const { calculateMeta } = require('../../utils/pagination');
const { sendClassRegisteredSms } = require('../../utils/senders/smsMessages');
const logger = require('../../utils/logger');
const getAll = async (query) => {
const page = parseInt(query.page) || 1;
const limit = Math.min(parseInt(query.limit) || 20, 200);
const skip = (page - 1) * limit;
const filter = {};
if (query.courseId) filter.course = query.courseId;
if (query.isActive !== undefined) filter.isActive = query.isActive === 'true';
const [items, total] = await Promise.all([
Class.find(filter)
.populate({ path: 'course', select: 'title type price' })
.populate({ path: 'professor', select: 'name surname' })
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
Class.countDocuments(filter)
]);
return { data: items, meta: calculateMeta(total, page, limit) };
};
const getOne = async (id) => {
const cls = await Class.findById(id)
.populate({ path: 'course', select: 'title type price' })
.populate({ path: 'professor', select: 'name surname phoneNumber' })
.populate({ path: 'students', select: 'name surname phoneNumber' })
.lean();
if (!cls) throw new AppError('CLASS_NOT_FOUND');
return cls;
};
const create = async (body) => {
const cls = await Class.create(body);
return getOne(cls._id);
};
const update = async (id, body) => {
const cls = await Class.findByIdAndUpdate(id, body, { new: true, runValidators: true })
.populate({ path: 'course', select: 'title' })
.lean();
if (!cls) throw new AppError('CLASS_NOT_FOUND');
return cls;
};
const remove = async (id) => {
const cls = await Class.findByIdAndDelete(id);
if (!cls) throw new AppError('CLASS_NOT_FOUND');
};
const registerUsers = async (classId, userIds) => {
const cls = await Class.findById(classId).populate({ path: 'course', select: 'title' });
if (!cls) throw new AppError('CLASS_NOT_FOUND');
const toAdd = (userIds || []).filter(
(id) => !cls.students.map((s) => s.toString()).includes(id.toString())
);
if (toAdd.length === 0) {
return getOne(classId);
}
cls.students.push(...toAdd);
await cls.save();
const classLabel = cls.name || cls.course?.title || 'کلاس';
const users = await User.find({ _id: { $in: toAdd } }).select('phoneNumber').lean();
await Promise.all(
users.map(async (user) => {
if (!user.phoneNumber) return;
try {
await sendClassRegisteredSms(user.phoneNumber, classLabel, user._id);
} catch (err) {
logger.error(`[registerUsers] SMS failed for ${user.phoneNumber}: ${err.message}`);
}
})
);
return getOne(classId);
};
const getMyClasses = async (userId, query = {}) => {
const page = parseInt(query.page) || 1;
const limit = Math.min(parseInt(query.limit) || 20, 200);
const skip = (page - 1) * limit;
const filter = { students: userId };
if (query.isActive !== undefined) filter.isActive = query.isActive === 'true';
const [items, total] = await Promise.all([
Class.find(filter)
.populate({ path: 'course', select: 'title type price description' })
.populate({ path: 'professor', select: 'name surname' })
.skip(skip).limit(limit).sort({ startDate: -1, createdAt: -1 }).lean(),
Class.countDocuments(filter)
]);
return { data: items, meta: calculateMeta(total, page, limit) };
};
module.exports = { getAll, getOne, create, update, remove, registerUsers, getMyClasses };
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
@@ -0,0 +1,25 @@
// /components/contactInquiries/contactInquiryController.js
const catchAsync = require('../../utils/catchAsync');
const contactInquiryService = require('./contactInquiryService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res) => {
const inquiry = await contactInquiryService.createInquiry(req.body);
return successResponse(res, 201, 'Contact inquiry submitted successfully', inquiry);
});
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await contactInquiryService.getAllInquiries(req.query);
return listResponse(res, 200, data, meta);
});
exports.getOne = catchAsync(async (req, res) => {
const inquiry = await contactInquiryService.getInquiryById(req.params.id);
return successResponse(res, 200, 'Contact inquiry retrieved successfully', inquiry);
});
exports.update = catchAsync(async (req, res) => {
const inquiry = await contactInquiryService.updateInquiry(req.params.id, req.body);
return successResponse(res, 200, 'Contact inquiry updated successfully', inquiry);
});
@@ -0,0 +1,81 @@
// /components/contactInquiries/contactInquiryModel.js
const mongoose = require('mongoose');
const CONTACT_METHODS = [
'WhatsApp',
'Telegram',
'Soroush',
'Bale',
'Eitaa',
'SMS',
'Call'
];
const CONTACT_STATUSES = [
'new',
'seen',
'call_later',
'contacted',
'closed'
];
const contactInquirySchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true
},
surname: {
type: String,
required: true,
trim: true
},
nationalIdCode: {
type: String,
trim: true,
index: true
},
phoneNumber: {
type: String,
trim: true,
index: true
},
email: {
type: String,
trim: true,
lowercase: true
},
message: {
type: String,
trim: true,
maxlength: 2000
},
preferredContactMethods: [{
type: String,
enum: CONTACT_METHODS
}],
courses: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Course'
}],
status: {
type: String,
enum: CONTACT_STATUSES,
default: 'new',
index: true
},
notes: {
type: String,
trim: true,
maxlength: 5000,
default: ''
}
}, {
timestamps: true
});
contactInquirySchema.statics.CONTACT_METHODS = CONTACT_METHODS;
contactInquirySchema.statics.CONTACT_STATUSES = CONTACT_STATUSES;
module.exports = mongoose.model('ContactInquiry', contactInquirySchema);
@@ -0,0 +1,35 @@
// /components/contactInquiries/contactInquiryRoutes.js
const express = require('express');
const contactInquiryController = require('./contactInquiryController');
const { validateCreateInquiry } = require('./contactInquiryValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
// Public — website contact form
router.post('/public/create', validateCreateInquiry, contactInquiryController.create);
// Admin
router.get(
'/admin/get-all',
authMiddleware,
perm.requires(PERMISSIONS.CONTACT_INQUIRIES_READ),
contactInquiryController.getAll
);
router.get(
'/admin/get-one/:id',
authMiddleware,
perm.requires(PERMISSIONS.CONTACT_INQUIRIES_READ),
contactInquiryController.getOne
);
router.put(
'/admin/update/:id',
authMiddleware,
perm.requires(PERMISSIONS.CONTACT_INQUIRIES_UPDATE),
contactInquiryController.update
);
module.exports = router;
@@ -0,0 +1,122 @@
// /components/contactInquiries/contactInquiryService.js
const ContactInquiry = require('./contactInquiryModel');
const Course = require('../courses/courseModel');
const AppError = require('../../utils/AppError');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const normalizePhone = (value) => {
if (!value) return undefined;
return String(value).replace(/[\s\-()]/g, '').trim();
};
const createInquiry = async (data) => {
const preferredContactMethods = Array.isArray(data.preferredContactMethods)
? [...new Set(data.preferredContactMethods)]
: [];
if (!preferredContactMethods.length) {
throw new AppError('VALIDATION_FAILED', {
preferredContactMethods: 'At least one preferred contact method is required'
}, 'حداقل یک روش تماس را انتخاب کنید.');
}
const phoneNumber = normalizePhone(data.phoneNumber || data.phone);
const email = data.email ? String(data.email).trim().toLowerCase() : undefined;
if (!phoneNumber && !email) {
throw new AppError('VALIDATION_FAILED', {
contact: 'Phone number or email is required'
}, 'شماره تلفن یا ایمیل الزامی است.');
}
const courseIds = Array.isArray(data.courses)
? [...new Set(data.courses.filter(Boolean).map(String))]
: [];
if (courseIds.length) {
const foundCount = await Course.countDocuments({
_id: { $in: courseIds },
showOnFrontend: { $ne: false }
});
if (foundCount !== courseIds.length) {
throw new AppError('COURSE_NOT_FOUND', null, 'یکی از دوره‌های انتخاب‌شده یافت نشد.');
}
}
const inquiry = await ContactInquiry.create({
name: data.name || data.firstName,
surname: data.surname || data.lastName,
nationalIdCode: data.nationalIdCode || data.nationalId || undefined,
phoneNumber,
email,
message: data.message || undefined,
preferredContactMethods,
courses: courseIds
});
return inquiry.populate('courses', 'title type price');
};
const getAllInquiries = async (queryParams) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams, 'createdAt', 'desc');
const filter = buildFilterQuery(
queryParams,
['name', 'surname', 'nationalIdCode', 'phoneNumber', 'email', 'notes', 'message'],
['page', 'limit', 'sortBy', 'sortOrder', 'q', 'lang', 'course', 'courseId', 'courses']
);
const courseFilter = queryParams.course || queryParams.courseId || queryParams.courses;
if (courseFilter) {
const ids = String(courseFilter).split(',').map((id) => id.trim()).filter(Boolean);
if (ids.length === 1) filter.courses = ids[0];
else if (ids.length > 1) filter.courses = { $in: ids };
}
const [data, totalCount] = await Promise.all([
ContactInquiry.find(filter)
.populate('courses', 'title type price')
.sort(sort)
.skip(skip)
.limit(limit),
ContactInquiry.countDocuments(filter)
]);
return { data, meta: calculateMeta(totalCount, page, limit) };
};
const getInquiryById = async (id) => {
const inquiry = await ContactInquiry.findById(id).populate('courses', 'title type price');
if (!inquiry) {
throw new AppError('NOT_FOUND', null, 'درخواست تماس یافت نشد.');
}
return inquiry;
};
const updateInquiry = async (id, updateData) => {
const inquiry = await ContactInquiry.findById(id);
if (!inquiry) {
throw new AppError('NOT_FOUND', null, 'درخواست تماس یافت نشد.');
}
if (updateData.status !== undefined) {
if (!ContactInquiry.CONTACT_STATUSES.includes(updateData.status)) {
throw new AppError('VALIDATION_FAILED', { status: 'Invalid status' }, 'وضعیت نامعتبر است.');
}
inquiry.status = updateData.status;
}
if (updateData.notes !== undefined) {
inquiry.notes = String(updateData.notes).slice(0, 5000);
}
await inquiry.save();
return getInquiryById(id);
};
module.exports = {
createInquiry,
getAllInquiries,
getInquiryById,
updateInquiry
};
@@ -0,0 +1,91 @@
// /components/contactInquiries/contactInquiryValidator.js
const AppError = require('../../utils/AppError');
const ContactInquiry = require('./contactInquiryModel');
const IRAN_MOBILE = /^(0?9\d{9}|\+989\d{9}|00989\d{9})$/;
const EMAIL_RE = /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/;
const isValidNationalId = (code) => {
if (!/^\d{10}$/.test(code)) return false;
if (/^(\d)\1{9}$/.test(code)) return false;
const check = Number(code[9]);
const sum = code
.split('')
.slice(0, 9)
.reduce((acc, digit, index) => acc + Number(digit) * (10 - index), 0);
const remainder = sum % 11;
return (remainder < 2 && check === remainder) || (remainder >= 2 && check === 11 - remainder);
};
const validateCreateInquiry = (req, res, next) => {
const body = req.body || {};
const details = {};
const name = (body.name || body.firstName || '').trim();
const surname = (body.surname || body.lastName || '').trim();
const nationalIdCode = (body.nationalIdCode || body.nationalId || '').trim();
const phoneNumber = String(body.phoneNumber || body.phone || '').replace(/[\s\-()]/g, '').trim();
const email = (body.email || '').trim();
const message = (body.message || '').trim();
const preferredContactMethods = Array.isArray(body.preferredContactMethods)
? body.preferredContactMethods
: [];
const courses = Array.isArray(body.courses) ? body.courses : [];
if (!name) details.name = 'نام الزامی است';
if (!surname) details.surname = 'نام خانوادگی الزامی است';
if (nationalIdCode && !isValidNationalId(nationalIdCode)) {
details.nationalIdCode = 'کد ملی معتبر نیست';
}
if (phoneNumber && !IRAN_MOBILE.test(phoneNumber)) {
details.phoneNumber = 'شماره موبایل معتبر نیست';
}
if (email && !EMAIL_RE.test(email)) {
details.email = 'ایمیل معتبر نیست';
}
if (!phoneNumber && !email) {
details.contact = 'شماره تلفن یا ایمیل الزامی است';
}
if (message.length > 2000) {
details.message = 'پیام نباید بیش از ۲۰۰۰ کاراکتر باشد';
}
const allowed = ContactInquiry.CONTACT_METHODS;
const invalidMethods = preferredContactMethods.filter((m) => !allowed.includes(m));
if (!preferredContactMethods.length) {
details.preferredContactMethods = 'حداقل یک روش تماس را انتخاب کنید';
} else if (invalidMethods.length) {
details.preferredContactMethods = 'روش تماس نامعتبر است';
}
if (courses.some((id) => typeof id !== 'string' && typeof id !== 'number')) {
details.courses = 'شناسه دوره نامعتبر است';
}
if (Object.keys(details).length) {
return next(new AppError('VALIDATION_FAILED', details));
}
req.body = {
name,
surname,
nationalIdCode: nationalIdCode || undefined,
phoneNumber: phoneNumber || undefined,
email: email || undefined,
message: message || undefined,
preferredContactMethods,
courses
};
return next();
};
module.exports = {
validateCreateInquiry
};
+43
View File
@@ -0,0 +1,43 @@
// /components/courses/courseController.js
const catchAsync = require('../../utils/catchAsync');
const courseService = require('./courseService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
const isPublicRequest = (req) => String(req.originalUrl || req.path || '').includes('/user/');
exports.create = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const course = await courseService.createCourse(req.body, actorId);
return successResponse(res, 201, 'Course created successfully', course);
});
exports.getOne = catchAsync(async (req, res) => {
const course = await courseService.getCourseById(req.params.id, {
publicOnly: isPublicRequest(req)
});
return successResponse(res, 200, 'Course retrieved successfully', course);
});
exports.getAll = catchAsync(async (req, res) => {
const { data, meta } = await courseService.getAllCourses(req.query, {
publicOnly: isPublicRequest(req)
});
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res) => {
const actorId = req.user?._id;
const course = await courseService.updateCourse(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Course updated successfully', course);
});
exports.delete = catchAsync(async (req, res) => {
await courseService.deleteCourse(req.params.id);
return successResponse(res, 200, 'Course deleted successfully');
});
exports.search = catchAsync(async (req, res) => {
const { data, meta } = await courseService.searchCourses(req.query);
return listResponse(res, 200, data, meta);
});
+95
View File
@@ -0,0 +1,95 @@
// /components/courses/courseModel.js
const mongoose = require('mongoose');
const discountSchema = new mongoose.Schema({
percent: { type: Number, required: true, min: 0, max: 100 },
validFrom: { type: Date, required: true },
validTo: { type: Date, required: true },
isActive: { type: Boolean, default: true }
});
const offerSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true },
description: { type: String, trim: true },
type: {
type: String,
enum: ['fullAdvancePayment', 'earlyBird', 'custom'],
required: true
},
percent: { type: Number, min: 0, max: 100 },
fixedAmount: { type: Number, min: 0 },
validFrom: { type: Date },
validTo: { type: Date },
isActive: { type: Boolean, default: true }
});
const courseSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
index: true
},
description: {
type: String,
trim: true
},
type: {
type: String,
enum: ['General', 'Private'],
required: true
},
price: {
type: Number,
required: true,
min: 0
},
rating: {
type: Number,
default: 0,
min: 0,
max: 5
},
isOfficial: {
type: Boolean,
default: false
},
/** When true, course appears on the public website */
showOnFrontend: {
type: Boolean,
default: true,
index: true
},
/** Number of sessions in the course */
sectionCount: {
type: Number,
default: 1,
min: 1
},
/** Hours per session */
hoursPerSection: {
type: Number,
default: 1.5,
min: 0
},
/** Bullet-point highlights shown on the frontend */
highlights: [{
type: String,
trim: true
}],
professor: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Professor'
},
discounts: [discountSchema],
offers: [offerSchema],
capacity: {
type: Number,
default: 30
}
}, {
timestamps: true
});
module.exports = mongoose.model('Course', courseSchema);
+24
View File
@@ -0,0 +1,24 @@
// /components/courses/courseRoutes.js
const express = require('express');
const courseController = require('./courseController');
const { validateCreateCourse, validateUpdateCourse } = require('./courseValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
// User Scope (Public or Authenticated reading)
router.get('/user/get-all', courseController.getAll);
router.get('/user/get-one/:id', courseController.getOne);
// Admin Scope
router.post('/admin/create', authMiddleware, perm.requires(PERMISSIONS.COURSES_CREATE), validateCreateCourse, courseController.create);
router.get('/admin/get-all', authMiddleware, perm.requires(PERMISSIONS.COURSES_READ), courseController.getAll);
router.get('/admin/search', authMiddleware, perm.requires(PERMISSIONS.COURSES_SEARCH), courseController.search);
router.get('/admin/get-one/:id', authMiddleware, perm.requires(PERMISSIONS.COURSES_READ), courseController.getOne);
router.put('/admin/update/:id', authMiddleware, perm.requires(PERMISSIONS.COURSES_UPDATE), validateUpdateCourse, courseController.update);
router.delete('/admin/delete/:id', authMiddleware, perm.requires(PERMISSIONS.COURSES_DELETE), courseController.delete);
module.exports = router;
+139
View File
@@ -0,0 +1,139 @@
// /components/courses/courseService.js
const Course = require('./courseModel');
const Professor = require('../professors/professorModel');
const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const normalizeHighlights = (value) => {
if (!Array.isArray(value)) return undefined;
return value.map((item) => String(item).trim()).filter(Boolean);
};
const createCourse = async (data, actorId = null) => {
if (data.professor) {
const professor = await Professor.findById(data.professor);
if (!professor) {
throw new AppError('PROFESSOR_NOT_FOUND');
}
}
const payload = {
...data,
highlights: normalizeHighlights(data.highlights) ?? data.highlights
};
const course = await Course.create(payload);
if (data.professor) {
await Professor.findByIdAndUpdate(data.professor, { $addToSet: { courses: course._id } });
}
eventEmitter.emit(EVENT_NAMES.COURSE_CREATED, { courseId: course._id, title: course.title, actorId });
return course;
};
const getCourseById = async (id, { publicOnly = false } = {}) => {
const filter = { _id: id };
if (publicOnly) filter.showOnFrontend = { $ne: false };
const course = await Course.findOne(filter).populate('professor', 'name surname title expertise email phoneNumber');
if (!course) {
throw new AppError('COURSE_NOT_FOUND');
}
return course;
};
const getAllCourses = async (queryParams, { publicOnly = false } = {}) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['title', 'description'], [
'page', 'limit', 'sortBy', 'sortOrder', 'q', 'lang', 'sort'
]);
if (publicOnly) {
// Include legacy docs that predate the field (treat missing as visible)
filter.showOnFrontend = { $ne: false };
}
// Support legacy ?sort=-createdAt style from frontend
let finalSort = sort;
if (queryParams.sort && typeof queryParams.sort === 'string') {
const raw = queryParams.sort.trim();
if (raw.startsWith('-')) {
finalSort = { [raw.slice(1)]: -1 };
} else {
finalSort = { [raw]: 1 };
}
}
const [courses, totalCount] = await Promise.all([
Course.find(filter).populate('professor', 'name surname').sort(finalSort).skip(skip).limit(limit),
Course.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit);
return { data: courses, meta };
};
const updateCourse = async (id, updateData, actorId = null) => {
const course = await Course.findById(id);
if (!course) {
throw new AppError('COURSE_NOT_FOUND');
}
if (updateData.professor && updateData.professor !== String(course.professor)) {
const professor = await Professor.findById(updateData.professor);
if (!professor) throw new AppError('PROFESSOR_NOT_FOUND');
if (course.professor) {
await Professor.findByIdAndUpdate(course.professor, { $pull: { courses: course._id } });
}
await Professor.findByIdAndUpdate(updateData.professor, { $addToSet: { courses: course._id } });
}
if (updateData.price !== undefined && updateData.price !== course.price) {
eventEmitter.emit(EVENT_NAMES.COURSE_PRICE_CHANGED, {
courseId: course._id,
oldPrice: course.price,
newPrice: updateData.price,
actorId
});
}
if (updateData.highlights !== undefined) {
updateData.highlights = normalizeHighlights(updateData.highlights) || [];
}
Object.assign(course, updateData);
await course.save();
return course;
};
const deleteCourse = async (id) => {
const course = await Course.findById(id);
if (!course) {
throw new AppError('COURSE_NOT_FOUND');
}
if (course.professor) {
await Professor.findByIdAndUpdate(course.professor, { $pull: { courses: course._id } });
}
await Course.findByIdAndDelete(id);
return null;
};
const searchCourses = async (queryParams) => {
return getAllCourses(queryParams);
};
module.exports = {
createCourse,
getCourseById,
getAllCourses,
updateCourse,
deleteCourse,
searchCourses
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
@@ -0,0 +1,11 @@
// /components/dashboard/dashboardController.js
'use strict';
const catchAsync = require('../../utils/catchAsync');
const dashboardService = require('./dashboardService');
const { successResponse } = require('../../utils/apiResponse');
exports.getAdminStats = catchAsync(async (req, res, next) => {
const stats = await dashboardService.getAdminStats();
return successResponse(res, 200, 'Dashboard stats retrieved successfully', stats);
});
+15
View File
@@ -0,0 +1,15 @@
// /components/dashboard/dashboardRoutes.js
'use strict';
const express = require('express');
const dashboardController = require('./dashboardController');
const authMiddleware = require('../../middlewares/authMiddleware');
const router = express.Router();
router.use(authMiddleware);
// GET /api/dashboard/admin/stats
router.get('/admin/stats', dashboardController.getAdminStats);
module.exports = router;
+123
View File
@@ -0,0 +1,123 @@
// /components/dashboard/dashboardService.js
'use strict';
const User = require('../users/userModel');
const Professor = require('../professors/professorModel');
const Course = require('../courses/courseModel');
const Session = require('../sessions/sessionModel');
/**
* Returns aggregated statistics for the admin dashboard
*/
const getAdminStats = async () => {
const [
totalUsers,
activeUsers,
totalProfessors,
activeProfessors,
totalCourses,
totalSessions,
recentSessions
] = await Promise.all([
User.countDocuments({}),
User.countDocuments({ isActive: true }),
Professor.countDocuments({}),
Professor.countDocuments({ isActive: true }),
Course.countDocuments({}),
Session.countDocuments({}),
Session.find({})
.sort({ day: -1 })
.limit(8)
.populate('course', 'title type')
.populate('class', 'name')
.populate('professor', 'name surname')
.lean()
]);
// Daily enrollment trend (last 14 days via User.createdAt)
const daysBack = 13;
const rangeStart = new Date();
rangeStart.setHours(0, 0, 0, 0);
rangeStart.setDate(rangeStart.getDate() - daysBack);
const dailyUsersRaw = await User.aggregate([
{ $match: { createdAt: { $gte: rangeStart } } },
{
$group: {
_id: {
year: { $year: '$createdAt' },
month: { $month: '$createdAt' },
day: { $dayOfMonth: '$createdAt' }
},
count: { $sum: 1 }
}
},
{ $sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1 } }
]);
// Fill every day in the range so the chart stays continuous
const dailyUsers = [];
for (let i = 0; i <= daysBack; i += 1) {
const date = new Date(rangeStart);
date.setDate(rangeStart.getDate() + i);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const match = dailyUsersRaw.find(
(item) => item._id.year === year && item._id.month === month && item._id.day === day
);
dailyUsers.push({
_id: { year, month, day },
date: date.toISOString(),
count: match ? match.count : 0
});
}
// Daily sessions trend (same window)
const dailySessionsRaw = await Session.aggregate([
{ $match: { createdAt: { $gte: rangeStart } } },
{
$group: {
_id: {
year: { $year: '$createdAt' },
month: { $month: '$createdAt' },
day: { $dayOfMonth: '$createdAt' }
},
count: { $sum: 1 }
}
},
{ $sort: { '_id.year': 1, '_id.month': 1, '_id.day': 1 } }
]);
const dailySessions = dailyUsers.map((day) => {
const match = dailySessionsRaw.find(
(item) =>
item._id.year === day._id.year &&
item._id.month === day._id.month &&
item._id.day === day._id.day
);
return {
_id: day._id,
date: day.date,
count: match ? match.count : 0
};
});
return {
totals: {
users: totalUsers,
activeUsers,
professors: totalProfessors,
activeProfessors,
courses: totalCourses,
sessions: totalSessions
},
recentSessions,
charts: {
dailyUsers,
dailySessions
}
};
};
module.exports = { getAdminStats };
+36
View File
@@ -0,0 +1,36 @@
// /components/files/fileController.js
const catchAsync = require('../../utils/catchAsync');
const fileService = require('./fileService');
const { successResponse } = require('../../utils/apiResponse');
const AppError = require('../../utils/AppError');
exports.uploadTemp = catchAsync(async (req, res, next) => {
if (!req.file) {
return next(new AppError('FILE_REQUIRED'));
}
const result = await fileService.uploadTempFile(
req.file.buffer,
req.file.originalname,
req.file.mimetype
);
return successResponse(res, 201, 'File uploaded to temp bucket successfully', result);
});
exports.getSignedUrl = catchAsync(async (req, res, next) => {
const { filename } = req.params;
const { bucket } = req.query;
const result = await fileService.getPresignedUrl(filename, bucket);
return successResponse(res, 200, 'Temporary presigned URL generated successfully', result);
});
exports.deleteFile = catchAsync(async (req, res, next) => {
const { filename } = req.params;
const { bucket } = req.query;
await fileService.deleteFile(filename, bucket);
return successResponse(res, 200, 'File deleted successfully');
});
+30
View File
@@ -0,0 +1,30 @@
// /components/files/fileRoutes.js
const express = require('express');
const multer = require('multer');
const fileController = require('./fileController');
const { validateGetSignedUrl } = require('./fileValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
// Memory Storage for Multer to stream directly to S3 temp bucket
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 25 * 1024 * 1024 } // 25MB limit
});
router.use(authMiddleware);
// User Scope
router.post('/user/upload-temp', perm.requires(PERMISSIONS.FILES_UPLOAD), upload.single('file'), fileController.uploadTemp);
router.get('/user/signed-url/:filename', perm.requires(PERMISSIONS.FILES_READ), validateGetSignedUrl, fileController.getSignedUrl);
// Admin Scope
router.post('/admin/upload-temp', perm.requires(PERMISSIONS.FILES_UPLOAD), upload.single('file'), fileController.uploadTemp);
router.get('/admin/signed-url/:filename', perm.requires(PERMISSIONS.FILES_READ), validateGetSignedUrl, fileController.getSignedUrl);
router.delete('/admin/delete/:filename', perm.requires(PERMISSIONS.FILES_DELETE), fileController.deleteFile);
module.exports = router;
+55
View File
@@ -0,0 +1,55 @@
// /components/files/fileService.js
const path = require('path');
const {
uploadToTempBucket,
commitTempFile,
generatePresignedUrl,
deleteFromBucket
} = require('../../utils/s3Client');
const AppError = require('../../utils/AppError');
const uploadTempFile = async (fileBuffer, originalName, mimeType) => {
if (!fileBuffer || !originalName) {
throw new AppError('FILE_REQUIRED');
}
const ext = path.extname(originalName);
const uniquePrefix = `${Date.now()}-${Math.round(Math.random() * 1E9)}`;
const tempFileName = `temp-${uniquePrefix}${ext}`;
const result = await uploadToTempBucket(fileBuffer, tempFileName, mimeType);
return {
tempFileName: result.tempFileName,
originalName,
mimeType
};
};
const getPresignedUrl = async (filename, bucket = null) => {
if (!filename) {
throw new AppError('VALIDATION_FAILED', null, 'Filename is required');
}
const signedUrl = await generatePresignedUrl(filename, bucket || undefined);
return {
filename,
presignedUrl: signedUrl,
expiresInSeconds: 900
};
};
const commitFile = async (tempFileName, targetFileName = null) => {
return commitTempFile(tempFileName, targetFileName);
};
const deleteFile = async (filename, bucket = null) => {
return deleteFromBucket(filename, bucket);
};
module.exports = {
uploadTempFile,
getPresignedUrl,
commitFile,
deleteFile
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
@@ -0,0 +1,45 @@
// /components/notifications/notificationController.js
const catchAsync = require('../../utils/catchAsync');
const notificationService = require('./notificationService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res, next) => {
const notification = await notificationService.createNotification(req.body);
return successResponse(res, 201, 'Notification created successfully', notification);
});
exports.getOne = catchAsync(async (req, res, next) => {
const notification = await notificationService.getNotificationById(req.params.id);
return successResponse(res, 200, 'Notification retrieved successfully', notification);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await notificationService.getAllNotifications(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const notification = await notificationService.updateNotification(req.params.id, req.body);
return successResponse(res, 200, 'Notification updated successfully', notification);
});
exports.delete = catchAsync(async (req, res, next) => {
await notificationService.deleteNotification(req.params.id);
return successResponse(res, 200, 'Notification deleted successfully');
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await notificationService.searchNotifications(req.query);
return listResponse(res, 200, data, meta);
});
exports.retry = catchAsync(async (req, res, next) => {
const result = await notificationService.retryNotification(req.params.id);
return successResponse(res, 200, 'Notification retried successfully', result);
});
exports.getMyNotifications = catchAsync(async (req, res, next) => {
const { data, meta } = await notificationService.getMyNotifications(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
@@ -0,0 +1,57 @@
// /components/notifications/notificationModel.js
const mongoose = require('mongoose');
const notificationSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
index: true
},
channel: {
type: String,
enum: ['email', 'sms', 'baleBot'],
required: true
},
subject: {
type: String,
trim: true
},
body: {
type: String,
required: true,
trim: true
},
status: {
type: String,
enum: ['pending', 'sent', 'delivered', 'failed', 'retrying'],
default: 'pending',
index: true
},
retryCount: {
type: Number,
default: 0
},
maxRetries: {
type: Number,
default: 3
},
lastError: {
type: String,
trim: true
},
sentAt: {
type: Date
},
deliveredAt: {
type: Date
},
relatedEvent: {
type: String,
trim: true
}
}, {
timestamps: true
});
module.exports = mongoose.model('Notification', notificationSchema);
@@ -0,0 +1,26 @@
// /components/notifications/notificationRoutes.js
const express = require('express');
const notificationController = require('./notificationController');
const { validateCreateNotification, validateUpdateNotification } = require('./notificationValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
// User Scope
router.get('/user/my-notifications', notificationController.getMyNotifications);
// Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.NOTIFICATIONS_CREATE), validateCreateNotification, notificationController.create);
router.get('/admin/get-all', perm.requires(PERMISSIONS.NOTIFICATIONS_READ), notificationController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.NOTIFICATIONS_SEARCH), notificationController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.NOTIFICATIONS_READ), notificationController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.NOTIFICATIONS_UPDATE), validateUpdateNotification, notificationController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.NOTIFICATIONS_DELETE), notificationController.delete);
router.post('/admin/:id/retry', perm.requires(PERMISSIONS.NOTIFICATIONS_RETRY), notificationController.retry);
module.exports = router;
@@ -0,0 +1,138 @@
// /components/notifications/notificationService.js
const Notification = require('./notificationModel');
const User = require('../users/userModel');
const AppError = require('../../utils/AppError');
const { sendEmail } = require('../../utils/senders/emailSender');
const { sendSMS } = require('../../utils/senders/smsSender');
const { sendBaleMessage } = require('../../utils/senders/baleBotSender');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const createNotification = async (data) => {
const user = await User.findById(data.user);
if (!user) throw new AppError('USER_NOT_FOUND');
const notification = await Notification.create(data);
// Attempt sending immediately
try {
if (data.channel === 'email' && user.email) {
await sendEmail({ to: user.email, subject: data.subject, body: data.body });
} else if (data.channel === 'baleBot') {
await sendBaleMessage({ chatId: user.phoneNumber, body: data.body });
} else {
await sendSMS({ phoneNumber: user.phoneNumber, body: data.body });
}
notification.status = 'sent';
notification.sentAt = new Date();
await notification.save();
} catch (err) {
notification.status = 'failed';
notification.lastError = err.message;
notification.retryCount = 1;
await notification.save();
}
return notification;
};
const getNotificationById = async (id) => {
const notification = await Notification.findById(id).populate('user', 'name surname username email phoneNumber');
if (!notification) {
throw new AppError('NOTIFICATION_NOT_FOUND');
}
return notification;
};
const getAllNotifications = async (queryParams) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['subject', 'body']);
const [notifications, totalCount] = await Promise.all([
Notification.find(filter).populate('user', 'name surname username').sort(sort).skip(skip).limit(limit),
Notification.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit);
return { data: notifications, meta };
};
const updateNotification = async (id, updateData) => {
const notification = await Notification.findById(id);
if (!notification) {
throw new AppError('NOTIFICATION_NOT_FOUND');
}
Object.assign(notification, updateData);
await notification.save();
return notification;
};
const deleteNotification = async (id) => {
const notification = await Notification.findById(id);
if (!notification) {
throw new AppError('NOTIFICATION_NOT_FOUND');
}
await Notification.findByIdAndDelete(id);
return null;
};
const searchNotifications = async (queryParams) => {
return getAllNotifications(queryParams);
};
const retryNotification = async (id) => {
const notification = await Notification.findById(id).populate('user');
if (!notification) {
throw new AppError('NOTIFICATION_NOT_FOUND');
}
const user = notification.user;
if (!user) throw new AppError('USER_NOT_FOUND');
notification.status = 'retrying';
notification.retryCount += 1;
await notification.save();
try {
if (notification.channel === 'email' && user.email) {
await sendEmail({ to: user.email, subject: notification.subject, body: notification.body });
} else if (notification.channel === 'baleBot') {
await sendBaleMessage({ chatId: user.phoneNumber, body: notification.body });
} else {
await sendSMS({ phoneNumber: user.phoneNumber, body: notification.body });
}
notification.status = 'sent';
notification.sentAt = new Date();
await notification.save();
return { success: true, notification };
} catch (err) {
notification.status = 'failed';
notification.lastError = err.message;
await notification.save();
throw new Error(`Retry attempt ${notification.retryCount} failed: ${err.message}`);
}
};
const getMyNotifications = async (userId, queryParams) => {
const filter = { user: userId, ...buildFilterQuery(queryParams) };
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const [notifications, totalCount] = await Promise.all([
Notification.find(filter).sort(sort).skip(skip).limit(limit),
Notification.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit);
return { data: notifications, meta };
};
module.exports = {
createNotification,
getNotificationById,
getAllNotifications,
updateNotification,
deleteNotification,
searchNotifications,
retryNotification,
getMyNotifications
};
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+48
View File
@@ -0,0 +1,48 @@
// /components/payments/paymentController.js
const catchAsync = require('../../utils/catchAsync');
const paymentService = require('./paymentService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const payment = await paymentService.createPayment(req.body, actorId);
return successResponse(res, 201, 'Payment created successfully', payment);
});
exports.getOne = catchAsync(async (req, res, next) => {
const payment = await paymentService.getPaymentById(req.params.id);
return successResponse(res, 200, 'Payment retrieved successfully', payment);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await paymentService.getAllPayments(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const payment = await paymentService.updatePayment(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Payment updated successfully', payment);
});
exports.delete = catchAsync(async (req, res, next) => {
await paymentService.deletePayment(req.params.id);
return successResponse(res, 200, 'Payment deleted successfully');
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await paymentService.searchPayments(req.query);
return listResponse(res, 200, data, meta);
});
exports.payUser = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const payment = await paymentService.addTransaction(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Payment transaction recorded', payment);
});
exports.getMyPayments = catchAsync(async (req, res, next) => {
const { data, meta } = await paymentService.getMyPayments(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
+71
View File
@@ -0,0 +1,71 @@
// /components/payments/paymentModel.js
'use strict';
const mongoose = require('mongoose');
const transactionSchema = new mongoose.Schema({
amount: { type: Number, required: true },
method: {
type: String,
enum: ['online', 'card', 'cash'],
default: 'card'
},
receiptNumber: { type: String, trim: true },
recordedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
date: { type: Date, default: Date.now }
}, { _id: true });
const paymentSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
classes: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Class'
}],
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course'
},
amount: {
type: Number,
required: true,
min: 0
},
paidAmount: {
type: Number,
default: 0,
min: 0
},
dueDate: {
type: Date
},
status: {
type: String,
enum: ['pending', 'partial', 'paid', 'overdue'],
default: 'pending'
},
transactions: [transactionSchema],
notes: { type: String, trim: true }
}, {
timestamps: true
});
// Auto-update status based on paid amount
paymentSchema.pre('save', function (next) {
if (this.paidAmount >= this.amount) {
this.status = 'paid';
} else if (this.paidAmount > 0) {
this.status = 'partial';
} else if (this.dueDate && new Date() > this.dueDate) {
this.status = 'overdue';
} else {
this.status = 'pending';
}
next();
});
module.exports = mongoose.model('Payment', paymentSchema);
+30
View File
@@ -0,0 +1,30 @@
// /components/payments/paymentRoutes.js
const express = require('express');
const paymentController = require('./paymentController');
const {
validateCreatePayment,
validateUpdatePayment,
validateAddTransaction
} = require('./paymentValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
// User Scope
router.get('/user/my-payments', paymentController.getMyPayments);
router.post('/user/pay/:id', validateAddTransaction, paymentController.payUser);
// Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.PAYMENTS_CREATE), validateCreatePayment, paymentController.create);
router.get('/admin/get-all', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.PAYMENTS_SEARCH), paymentController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PAYMENTS_READ), paymentController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.PAYMENTS_UPDATE), validateUpdatePayment, paymentController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PAYMENTS_DELETE), paymentController.delete);
module.exports = router;
+120
View File
@@ -0,0 +1,120 @@
// /components/payments/paymentService.js
'use strict';
const Payment = require('./paymentModel');
const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { calculateMeta } = require('../../utils/pagination');
const getAllPayments = async (query) => {
const page = parseInt(query.page) || 1;
const limit = Math.min(parseInt(query.limit) || 20, 200);
const skip = (page - 1) * limit;
const filter = {};
if (query.userId) filter.user = query.userId;
if (query.status) filter.status = query.status;
const [items, total] = await Promise.all([
Payment.find(filter)
.populate({ path: 'user', select: 'name surname' })
.populate({ path: 'classes', select: 'name' })
.populate({ path: 'course', select: 'title' })
.skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
Payment.countDocuments(filter)
]);
return { data: items, meta: calculateMeta(total, page, limit) };
};
const getPaymentById = async (id) => {
const payment = await Payment.findById(id)
.populate({ path: 'user', select: 'name surname phoneNumber' })
.populate({ path: 'classes', select: 'name tuitionFee' })
.populate({ path: 'course', select: 'title price' })
.lean();
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
return payment;
};
const createPayment = async (body, actorId = null) => {
const payment = await Payment.create({
...body,
paidAmount: body.paidAmount || 0
});
if (actorId) {
eventEmitter.emit(EVENT_NAMES.PAYMENT_CREATED, {
paymentId: payment._id,
userId: payment.user,
actorId
});
}
return getPaymentById(payment._id);
};
const updatePayment = async (id, body, actorId = null) => {
const payment = await Payment.findById(id);
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
const previousStatus = payment.status;
Object.assign(payment, body);
await payment.save();
if (body.status && body.status !== previousStatus) {
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
paymentId: payment._id,
userId: payment.user,
oldStatus: previousStatus,
newStatus: payment.status,
actorId
});
}
return getPaymentById(payment._id);
};
const deletePayment = async (id) => {
const payment = await Payment.findByIdAndDelete(id);
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
return null;
};
const searchPayments = async (query) => getAllPayments(query);
const addTransaction = async (paymentId, trxData, actorId = null) => {
const payment = await Payment.findById(paymentId);
if (!payment) throw new AppError('PAYMENT_NOT_FOUND');
payment.transactions.push({
...trxData,
recordedBy: actorId || trxData.recordedBy,
date: trxData.date || new Date()
});
payment.paidAmount = payment.transactions.reduce((sum, t) => sum + (t.amount || 0), 0);
await payment.save();
return getPaymentById(paymentId);
};
const getMyPayments = async (userId, query = {}) => {
return getAllPayments({ ...query, userId });
};
module.exports = {
getAllPayments,
getPaymentById,
createPayment,
updatePayment,
deletePayment,
searchPayments,
addTransaction,
getMyPayments,
// Aliases for older call sites
getAll: getAllPayments,
getOne: getPaymentById,
create: createPayment,
recordTransaction: addTransaction,
remove: deletePayment
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
@@ -0,0 +1,35 @@
// /components/professors/professorController.js
const catchAsync = require('../../utils/catchAsync');
const professorService = require('./professorService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res, next) => {
const professor = await professorService.createProfessor(req.body);
return successResponse(res, 201, 'Professor created successfully', professor);
});
exports.getOne = catchAsync(async (req, res, next) => {
const professor = await professorService.getProfessorById(req.params.id);
return successResponse(res, 200, 'Professor retrieved successfully', professor);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await professorService.getAllProfessors(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const professor = await professorService.updateProfessor(req.params.id, req.body);
return successResponse(res, 200, 'Professor updated successfully', professor);
});
exports.delete = catchAsync(async (req, res, next) => {
await professorService.deleteProfessor(req.params.id);
return successResponse(res, 200, 'Professor deleted successfully');
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await professorService.searchProfessors(req.query);
return listResponse(res, 200, data, meta);
});
+51
View File
@@ -0,0 +1,51 @@
// /components/professors/professorModel.js
const mongoose = require('mongoose');
const professorSchema = new mongoose.Schema({
nationalIdCode: {
type: String,
required: true,
unique: true,
trim: true,
index: true
},
name: {
type: String,
required: true,
trim: true
},
surname: {
type: String,
required: true,
trim: true
},
phoneNumber: {
type: String,
required: true,
unique: true,
trim: true,
index: true
},
email: {
type: String,
trim: true,
lowercase: true
},
expertise: [{
type: String,
trim: true
}],
courses: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Course'
}],
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true
});
module.exports = mongoose.model('Professor', professorSchema);
+21
View File
@@ -0,0 +1,21 @@
// /components/professors/professorRoutes.js
const express = require('express');
const professorController = require('./professorController');
const { validateCreateProfessor, validateUpdateProfessor } = require('./professorValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
router.post('/admin/create', perm.requires(PERMISSIONS.PROFESSORS_CREATE), validateCreateProfessor, professorController.create);
router.get('/admin/get-all', perm.requires(PERMISSIONS.PROFESSORS_READ), professorController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.PROFESSORS_SEARCH), professorController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.PROFESSORS_READ), professorController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.PROFESSORS_UPDATE), validateUpdateProfessor, professorController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.PROFESSORS_DELETE), professorController.delete);
module.exports = router;
+79
View File
@@ -0,0 +1,79 @@
// /components/professors/professorService.js
const Professor = require('./professorModel');
const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const createProfessor = async (data) => {
const existing = await Professor.findOne({
$or: [
{ nationalIdCode: data.nationalIdCode },
{ phoneNumber: data.phoneNumber },
...(data.email ? [{ email: data.email }] : [])
]
});
if (existing) {
throw new AppError('PROFESSOR_ALREADY_EXISTS');
}
const professor = await Professor.create(data);
eventEmitter.emit(EVENT_NAMES.PROFESSOR_CREATED, { professorId: professor._id, name: `${professor.name} ${professor.surname}` });
return professor;
};
const getProfessorById = async (id) => {
const professor = await Professor.findById(id).populate('courses', 'title type price');
if (!professor) {
throw new AppError('PROFESSOR_NOT_FOUND');
}
return professor;
};
const getAllProfessors = async (queryParams) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['name', 'surname', 'nationalIdCode', 'phoneNumber', 'email', 'expertise']);
const [professors, totalCount] = await Promise.all([
Professor.find(filter).populate('courses', 'title').sort(sort).skip(skip).limit(limit),
Professor.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit);
return { data: professors, meta };
};
const updateProfessor = async (id, updateData) => {
const professor = await Professor.findById(id);
if (!professor) {
throw new AppError('PROFESSOR_NOT_FOUND');
}
Object.assign(professor, updateData);
await professor.save();
return professor;
};
const deleteProfessor = async (id) => {
const professor = await Professor.findById(id);
if (!professor) {
throw new AppError('PROFESSOR_NOT_FOUND');
}
await Professor.findByIdAndDelete(id);
return null;
};
const searchProfessors = async (queryParams) => {
return getAllProfessors(queryParams);
};
module.exports = {
createProfessor,
getProfessorById,
getAllProfessors,
updateProfessor,
deleteProfessor,
searchProfessors
};
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+35
View File
@@ -0,0 +1,35 @@
// /components/roles/roleController.js
const catchAsync = require('../../utils/catchAsync');
const roleService = require('./roleService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.createRole = catchAsync(async (req, res, next) => {
const role = await roleService.createRole(req.body);
return successResponse(res, 201, 'Role created successfully', role);
});
exports.getRole = catchAsync(async (req, res, next) => {
const role = await roleService.getRole(req.params.id);
return successResponse(res, 200, 'Role retrieved successfully', role);
});
exports.getAllRoles = catchAsync(async (req, res, next) => {
const { data, meta } = await roleService.getAllRoles(req.query);
return listResponse(res, 200, data, meta);
});
exports.updateRole = catchAsync(async (req, res, next) => {
const role = await roleService.updateRole(req.params.id, req.body);
return successResponse(res, 200, 'Role updated successfully', role);
});
exports.deleteRole = catchAsync(async (req, res, next) => {
await roleService.deleteRole(req.params.id);
return successResponse(res, 200, 'Role deleted successfully');
});
exports.searchRoles = catchAsync(async (req, res, next) => {
const { data, meta } = await roleService.searchRoles(req.query);
return listResponse(res, 200, data, meta);
});
+29
View File
@@ -0,0 +1,29 @@
// /components/roles/roleModel.js
const mongoose = require('mongoose');
const roleSchema = new mongoose.Schema({
name: {
type: String,
required: true,
unique: true,
trim: true,
index: true
},
description: {
type: String,
trim: true
},
permissions: [{
type: String,
trim: true
}],
isSystem: {
type: Boolean,
default: false
}
}, {
timestamps: true
});
module.exports = mongoose.model('Role', roleSchema);
+21
View File
@@ -0,0 +1,21 @@
// /components/roles/roleRoutes.js
const express = require('express');
const roleController = require('./roleController');
const { validateCreateRole, validateUpdateRole } = require('./roleValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
router.post('/admin/create', perm.requires(PERMISSIONS.ROLES_CREATE), validateCreateRole, roleController.createRole);
router.get('/admin/get-all', perm.requires(PERMISSIONS.ROLES_READ), roleController.getAllRoles);
router.get('/admin/search', perm.requires(PERMISSIONS.ROLES_SEARCH), roleController.searchRoles);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.ROLES_READ), roleController.getRole);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.ROLES_UPDATE), validateUpdateRole, roleController.updateRole);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.ROLES_DELETE), roleController.deleteRole);
module.exports = router;
+84
View File
@@ -0,0 +1,84 @@
// /components/roles/roleService.js
const Role = require('./roleModel');
const AppError = require('../../utils/AppError');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const createRole = async (roleData) => {
const existing = await Role.findOne({ name: roleData.name });
if (existing) {
throw new AppError('ROLE_ALREADY_EXISTS');
}
const role = await Role.create(roleData);
return role;
};
const getRole = async (id) => {
const role = await Role.findById(id);
if (!role) {
throw new AppError('ROLE_NOT_FOUND');
}
return role;
};
const getAllRoles = async (queryParams = {}) => {
const { page, limit, skip, sort } = parsePaginationAndSort(queryParams);
const filter = buildFilterQuery(queryParams, ['name', 'description']);
const [roles, totalCount] = await Promise.all([
Role.find(filter).sort(sort).skip(skip).limit(limit),
Role.countDocuments(filter)
]);
const meta = calculateMeta(totalCount, page, limit);
return { data: roles, meta };
};
const updateRole = async (id, updateData) => {
const role = await Role.findById(id);
if (!role) {
throw new AppError('ROLE_NOT_FOUND');
}
if (role.isSystem) {
throw new AppError('SYSTEM_ROLE_PROTECTED');
}
if (updateData.name && updateData.name !== role.name) {
const existing = await Role.findOne({ name: updateData.name });
if (existing) {
throw new AppError('ROLE_ALREADY_EXISTS');
}
}
Object.assign(role, updateData);
await role.save();
return role;
};
const deleteRole = async (id) => {
const role = await Role.findById(id);
if (!role) {
throw new AppError('ROLE_NOT_FOUND');
}
if (role.isSystem) {
throw new AppError('SYSTEM_ROLE_PROTECTED');
}
await Role.findByIdAndDelete(id);
return null;
};
const searchRoles = async (queryParams) => {
return getAllRoles(queryParams);
};
module.exports = {
createRole,
getRole,
getAllRoles,
updateRole,
deleteRole,
searchRoles
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+63
View File
@@ -0,0 +1,63 @@
// /components/sessions/sessionController.js
const catchAsync = require('../../utils/catchAsync');
const sessionService = require('./sessionService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.create = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const session = await sessionService.createSession(req.body, actorId);
return successResponse(res, 201, 'Session created successfully', session);
});
exports.getOne = catchAsync(async (req, res, next) => {
const session = await sessionService.getSessionById(req.params.id);
return successResponse(res, 200, 'Session retrieved successfully', session);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await sessionService.getAllSessions(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const session = await sessionService.updateSession(req.params.id, req.body, actorId);
return successResponse(res, 200, 'Session updated successfully', session);
});
exports.delete = catchAsync(async (req, res, next) => {
await sessionService.deleteSession(req.params.id);
return successResponse(res, 200, 'Session deleted successfully');
});
exports.bulkDelete = catchAsync(async (req, res, next) => {
const result = await sessionService.bulkDeleteSessions(req.body.ids || req.body.sessionIds);
return successResponse(res, 200, 'Sessions deleted successfully', result);
});
exports.bulkUpdateStatus = catchAsync(async (req, res, next) => {
const actorId = req.user?._id;
const result = await sessionService.bulkUpdateSessionStatus(
req.body.ids || req.body.sessionIds,
req.body.status,
actorId
);
return successResponse(res, 200, 'Session statuses updated successfully', result);
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await sessionService.searchSessions(req.query);
return listResponse(res, 200, data, meta);
});
exports.updateAttendance = catchAsync(async (req, res, next) => {
const recordedBy = req.user?._id;
const session = await sessionService.updateSessionAttendance(req.params.id, req.body.attendanceList, recordedBy);
return successResponse(res, 200, 'Session attendance updated successfully', session);
});
exports.getMySessions = catchAsync(async (req, res, next) => {
const { data, meta } = await sessionService.getMySessions(req.user._id, req.query);
return listResponse(res, 200, data, meta);
});
+89
View File
@@ -0,0 +1,89 @@
// /components/sessions/sessionModel.js
const mongoose = require('mongoose');
const attendanceRecordSchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
status: {
type: String,
enum: ['present', 'absent', 'late', 'excused'],
default: 'present'
},
note: {
type: String,
trim: true
},
recordedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}
}, {
_id: false
});
const sessionSchema = new mongoose.Schema({
course: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Course',
required: true,
index: true
},
class: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Class',
required: true,
index: true
},
professor: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Professor',
required: true,
index: true
},
day: {
type: Date,
required: true,
index: true
},
startTime: {
type: String,
required: true,
trim: true
},
endTime: {
type: String,
required: true,
trim: true
},
place: {
type: String,
trim: true
},
topic: {
type: String,
trim: true
},
note: {
type: String,
trim: true,
maxlength: 5000
},
status: {
type: String,
enum: ['scheduled', 'held', 'cancelled'],
default: 'scheduled'
},
attendanceList: [attendanceRecordSchema],
reminderSentAt: {
type: Date,
default: null
}
}, {
timestamps: true
});
module.exports = mongoose.model('Session', sessionSchema);
+32
View File
@@ -0,0 +1,32 @@
// /components/sessions/sessionRoutes.js
const express = require('express');
const sessionController = require('./sessionController');
const {
validateCreateSession,
validateUpdateSession,
validateUpdateAttendanceList
} = require('./sessionValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
router.use(authMiddleware);
// User Scope
router.get('/user/my-sessions', sessionController.getMySessions);
// Admin Scope
router.post('/admin/create', perm.requires(PERMISSIONS.SESSIONS_CREATE), validateCreateSession, sessionController.create);
router.get('/admin/get-all', perm.requires(PERMISSIONS.SESSIONS_READ), sessionController.getAll);
router.get('/admin/search', perm.requires(PERMISSIONS.SESSIONS_SEARCH), sessionController.search);
router.get('/admin/get-one/:id', perm.requires(PERMISSIONS.SESSIONS_READ), sessionController.getOne);
router.put('/admin/update/:id', perm.requires(PERMISSIONS.SESSIONS_UPDATE), validateUpdateSession, sessionController.update);
router.delete('/admin/delete/:id', perm.requires(PERMISSIONS.SESSIONS_DELETE), sessionController.delete);
router.post('/admin/bulk-delete', perm.requires(PERMISSIONS.SESSIONS_DELETE), sessionController.bulkDelete);
router.post('/admin/bulk-status', perm.requires(PERMISSIONS.SESSIONS_UPDATE), sessionController.bulkUpdateStatus);
router.put('/admin/:id/attendance', perm.requires(PERMISSIONS.SESSIONS_ATTENDANCE), validateUpdateAttendanceList, sessionController.updateAttendance);
module.exports = router;
+299
View File
@@ -0,0 +1,299 @@
// /components/sessions/sessionService.js
const Session = require('./sessionModel');
const Course = require('../courses/courseModel');
const Class = require('../classes/classModel');
const Professor = require('../professors/professorModel');
const AppError = require('../../utils/AppError');
const eventEmitter = require('../../events/eventEmitter');
const EVENT_NAMES = require('../../constants/eventNames');
const { parsePaginationAndSort, buildFilterQuery, calculateMeta } = require('../../utils/pagination');
const STATUS_MAP = {
scheduled: 'scheduled',
held: 'held',
cancelled: 'cancelled',
Scheduled: 'scheduled',
Held: 'held',
Cancelled: 'cancelled',
canceled: 'cancelled',
Canceled: 'cancelled'
};
const normalizeSessionPayload = (data) => {
const payload = { ...data };
if (payload.date && !payload.day) {
payload.day = payload.date;
}
delete payload.date;
if (payload.status) {
payload.status = STATUS_MAP[payload.status] || payload.status;
}
return payload;
};
const createSession = async (data, actorId = null) => {
// Support bulk create from dashboard: { sessions: [...], courseId, professorId, classId }
if (Array.isArray(data.sessions)) {
const created = [];
for (const item of data.sessions) {
const session = await createSession({
...item,
course: item.course || data.courseId || data.course,
class: item.class || data.classId || data.class,
professor: item.professor || data.professorId || data.professor
}, actorId);
created.push(session);
}
return created;
}
const payload = normalizeSessionPayload(data);
if (!payload.day) {
throw new AppError('VALIDATION_FAILED', { day: 'Date is required' }, 'تاریخ جلسه الزامی است.');
}
if (!payload.startTime || !payload.endTime) {
throw new AppError('VALIDATION_FAILED', { time: 'Start and end time are required' }, 'ساعت شروع و پایان الزامی است.');
}
const course = await Course.findById(payload.course);
if (!course) throw new AppError('COURSE_NOT_FOUND');
const classItem = await Class.findById(payload.class);
if (!classItem) throw new AppError('NOT_FOUND', null, 'کلاس یافت نشد.');
const professor = await Professor.findById(payload.professor);
if (!professor) throw new AppError('PROFESSOR_NOT_FOUND');
const session = await Session.create(payload);
eventEmitter.emit(EVENT_NAMES.SESSION_CREATED, {
sessionId: session._id,
courseId: session.course,
classId: session.class,
actorId
});
return session;
};
const getSessionById = async (id) => {
const session = await Session.findById(id)
.populate('course', 'title type')
.populate({
path: 'class',
select: 'name students capacity',
populate: { path: 'students', select: 'name surname nationalIdCode phoneNumber' }
})
.populate('professor', 'name surname email phoneNumber')
.populate('attendanceList.user', 'name surname username nationalIdCode');
if (!session) {
throw new AppError('SESSION_NOT_FOUND');
}
return session;
};
const startOfToday = () => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
};
/** Upcoming soonest first, then most recent past — nearest attendance date. */
const sortByClosestAttendance = (sessions) => {
const today = startOfToday().getTime();
return [...sessions].sort((a, b) => {
const da = new Date(a.day || a.date || 0).getTime();
const db = new Date(b.day || b.date || 0).getTime();
const aUpcoming = da >= today;
const bUpcoming = db >= today;
if (aUpcoming && bUpcoming) return da - db;
if (!aUpcoming && !bUpcoming) return db - da;
return aUpcoming ? -1 : 1;
});
};
const populateSessionList = (query) =>
query
.populate('course', 'title type')
.populate('class', 'name')
.populate('professor', 'name surname');
const getAllSessions = async (queryParams) => {
const { page, limit, skip } = parsePaginationAndSort(queryParams, 'day', 'asc');
const filter = buildFilterQuery(queryParams, ['topic', 'place', 'note'], [
'page',
'limit',
'sortBy',
'sortOrder',
'q',
'lang',
'courseId',
'classId',
'professorId',
'class' // handled below so we always cast consistently
]);
if (queryParams.courseId) filter.course = queryParams.courseId;
const classFilter = queryParams.classId || queryParams.class;
if (classFilter) {
filter.class = classFilter;
}
if (queryParams.professorId) filter.professor = queryParams.professorId;
if (filter.status) {
filter.status = STATUS_MAP[filter.status] || filter.status;
}
const [matched, totalCount] = await Promise.all([
populateSessionList(Session.find(filter)).lean(),
Session.countDocuments(filter)
]);
const sessions = sortByClosestAttendance(matched).slice(skip, skip + limit);
const meta = calculateMeta(totalCount, page, limit);
return { data: sessions, meta };
};
const updateSession = async (id, updateData, actorId = null) => {
const session = await Session.findById(id);
if (!session) {
throw new AppError('SESSION_NOT_FOUND');
}
const payload = normalizeSessionPayload(updateData);
if (payload.day === null || payload.day === '') {
throw new AppError('VALIDATION_FAILED', { day: 'Date is required' }, 'تاریخ جلسه الزامی است.');
}
if (payload.status === 'cancelled' && session.status !== 'cancelled') {
eventEmitter.emit(EVENT_NAMES.SESSION_CANCELLED, {
sessionId: session._id,
courseId: session.course,
topic: payload.topic || session.topic,
actorId
});
}
Object.assign(session, payload);
await session.save();
return getSessionById(session._id);
};
const deleteSession = async (id) => {
const session = await Session.findById(id);
if (!session) {
throw new AppError('SESSION_NOT_FOUND');
}
await Session.findByIdAndDelete(id);
return null;
};
const normalizeIds = (ids) => {
if (!Array.isArray(ids)) return [];
return [...new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))];
};
const bulkDeleteSessions = async (ids) => {
const sessionIds = normalizeIds(ids);
if (!sessionIds.length) {
throw new AppError('VALIDATION_FAILED', { ids: 'Required' }, 'هیچ جلسه‌ای انتخاب نشده است.');
}
const result = await Session.deleteMany({ _id: { $in: sessionIds } });
return { deletedCount: result.deletedCount || 0 };
};
const bulkUpdateSessionStatus = async (ids, status, actorId = null) => {
const sessionIds = normalizeIds(ids);
if (!sessionIds.length) {
throw new AppError('VALIDATION_FAILED', { ids: 'Required' }, 'هیچ جلسه‌ای انتخاب نشده است.');
}
const normalizedStatus = STATUS_MAP[status] || status;
if (!['scheduled', 'held', 'cancelled'].includes(normalizedStatus)) {
throw new AppError('VALIDATION_FAILED', { status: 'Invalid' }, 'وضعیت جلسه نامعتبر است.');
}
const sessions = await Session.find({ _id: { $in: sessionIds } });
let updatedCount = 0;
for (const session of sessions) {
const previousStatus = session.status;
session.status = normalizedStatus;
await session.save();
updatedCount += 1;
if (normalizedStatus === 'cancelled' && previousStatus !== 'cancelled') {
eventEmitter.emit(EVENT_NAMES.SESSION_CANCELLED, {
sessionId: session._id,
courseId: session.course,
classId: session.class,
actorId
});
}
}
return { updatedCount, status: normalizedStatus };
};
const searchSessions = async (queryParams) => {
return getAllSessions(queryParams);
};
const updateSessionAttendance = async (sessionId, attendanceList, recordedBy = null) => {
const session = await Session.findById(sessionId);
if (!session) throw new AppError('SESSION_NOT_FOUND');
const list = Array.isArray(attendanceList) ? attendanceList : [];
session.attendanceList = list.map((record) => ({
user: record.user || record.userId,
status: record.status || 'present',
note: record.note || '',
recordedBy
}));
await session.save();
eventEmitter.emit(EVENT_NAMES.ATTENDANCE_RECORDED, {
sessionId: session._id,
recordCount: list.length,
recordedBy
});
return getSessionById(sessionId);
};
const getMySessions = async (userId, queryParams) => {
const userClasses = await Class.find({ students: userId }).select('_id');
const classIds = userClasses.map((c) => c._id);
const filter = { class: { $in: classIds } };
const { page, limit, skip } = parsePaginationAndSort(queryParams, 'day', 'asc');
const [matched, totalCount] = await Promise.all([
populateSessionList(Session.find(filter)).lean(),
Session.countDocuments(filter)
]);
const sessions = sortByClosestAttendance(matched).slice(skip, skip + limit);
const meta = calculateMeta(totalCount, page, limit);
return { data: sessions, meta };
};
module.exports = {
createSession,
getSessionById,
getAllSessions,
updateSession,
deleteSession,
bulkDeleteSessions,
bulkUpdateSessionStatus,
searchSessions,
updateSessionAttendance,
getMySessions
};
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+61
View File
@@ -0,0 +1,61 @@
// /components/users/userController.js
const catchAsync = require('../../utils/catchAsync');
const userService = require('./userService');
const { successResponse, listResponse } = require('../../utils/apiResponse');
exports.signUp = catchAsync(async (req, res, next) => {
const user = await userService.signUp(req.body);
return successResponse(res, 201, 'Signed up successfully', user);
});
exports.getSelf = catchAsync(async (req, res, next) => {
const user = await userService.getUserById(req.user._id);
return successResponse(res, 200, 'User profile retrieved', user);
});
exports.updateSelf = catchAsync(async (req, res, next) => {
delete req.body.role;
delete req.body.isActive;
const user = await userService.updateUser(req.user._id, req.body);
return successResponse(res, 200, 'Profile updated successfully', user);
});
exports.createAdmin = catchAsync(async (req, res, next) => {
const user = await userService.createUserAdmin(req.body);
return successResponse(res, 201, 'User created successfully', user);
});
exports.getOne = catchAsync(async (req, res, next) => {
const user = await userService.getUserById(req.params.id);
return successResponse(res, 200, 'User retrieved successfully', user);
});
exports.getAll = catchAsync(async (req, res, next) => {
const { data, meta } = await userService.getAllUsers(req.query);
return listResponse(res, 200, data, meta);
});
exports.update = catchAsync(async (req, res, next) => {
const user = await userService.updateUser(req.params.id, req.body);
return successResponse(res, 200, 'User updated successfully', user);
});
exports.delete = catchAsync(async (req, res, next) => {
await userService.deleteUser(req.params.id);
return successResponse(res, 200, 'User deleted successfully');
});
exports.search = catchAsync(async (req, res, next) => {
const { data, meta } = await userService.searchUsers(req.query);
return listResponse(res, 200, data, meta);
});
exports.enroll = catchAsync(async (req, res, next) => {
const { userId } = req.params;
const { courseId } = req.body;
const actorId = req.user._id;
const result = await userService.enrollUserInCourse(userId, courseId, actorId);
return successResponse(res, 200, 'User enrolled into course successfully', result);
});
+87
View File
@@ -0,0 +1,87 @@
// /components/users/userModel.js
const mongoose = require('mongoose');
const refreshTokenSchema = new mongoose.Schema({
token: { type: String, required: true },
expiresAt: { type: Date, required: true },
createdAt: { type: Date, default: Date.now }
});
const userSchema = new mongoose.Schema({
nationalIdCode: {
type: String,
required: true,
unique: true,
trim: true,
index: true
},
name: {
type: String,
required: true,
trim: true
},
surname: {
type: String,
required: true,
trim: true
},
phoneNumber: {
type: String,
required: true,
unique: true,
trim: true,
index: true
},
preferredMessenger: {
type: [{
type: String,
enum: ['Bale', 'WhatsApp', 'Telegram', 'SMS', 'Email']
}],
default: ['SMS']
},
email: {
type: String,
sparse: true,
trim: true,
lowercase: true,
match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, 'Please fill a valid email address']
},
address: {
type: String,
trim: true
},
username: {
type: String,
required: true,
unique: true,
trim: true,
index: true
},
passwordHash: {
type: String,
required: true
},
role: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Role',
required: true
},
courses: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Course'
}],
certificates: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Certificate'
}],
refreshTokens: [refreshTokenSchema],
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true
});
module.exports = mongoose.model('User', userSchema);
+31
View File
@@ -0,0 +1,31 @@
// /components/users/userRoutes.js
const express = require('express');
const userController = require('./userController');
const {
validateSignUp,
validateCreateUserAdmin,
validateUpdateUser,
validateEnroll
} = require('./userValidator');
const authMiddleware = require('../../middlewares/authMiddleware');
const perm = require('../../middlewares/permissionMiddleware');
const { PERMISSIONS } = require('../../constants/permissions');
const router = express.Router();
// User Scope (Self-Service)
router.post('/user/sign-up', validateSignUp, userController.signUp);
router.get('/user/get-self', authMiddleware, userController.getSelf);
router.put('/user/update-self', authMiddleware, validateUpdateUser, userController.updateSelf);
// Admin Scope
router.post('/admin/create', authMiddleware, perm.requires(PERMISSIONS.USERS_CREATE), validateCreateUserAdmin, userController.createAdmin);
router.get('/admin/get-all', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getAll);
router.get('/admin/search', authMiddleware, perm.requires(PERMISSIONS.USERS_SEARCH), userController.search);
router.get('/admin/get-one/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_READ), userController.getOne);
router.put('/admin/update/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_UPDATE), validateUpdateUser, userController.update);
router.delete('/admin/delete/:id', authMiddleware, perm.requires(PERMISSIONS.USERS_DELETE), userController.delete);
router.post('/admin/:userId/enroll', authMiddleware, perm.requires(PERMISSIONS.USERS_ENROLL), validateEnroll, userController.enroll);
module.exports = router;
+232
View File
@@ -0,0 +1,232 @@
// /components/users/userService.js
'use strict';
const User = require('./userModel');
const Role = require('../roles/roleModel');
const bcrypt = require('bcryptjs');
const AppError = require('../../utils/AppError');
const { calculateMeta } = require('../../utils/pagination');
const { generateUsername, generateSimplePassword } = require('../../utils/credentials');
const { sendAccountCreatedSms } = require('../../utils/senders/smsMessages');
const logger = require('../../utils/logger');
const POPULATE_ROLE = { path: 'role', select: 'name permissions' };
const SAFE_FIELDS = '-passwordHash -refreshTokens';
const ALLOWED_MESSENGERS = ['Bale', 'WhatsApp', 'Telegram', 'SMS', 'Email'];
const normalizePreferredMessengers = (value) => {
if (value == null || value === '') return undefined;
const list = Array.isArray(value) ? value : [value];
const cleaned = [...new Set(list.map(String).filter((v) => ALLOWED_MESSENGERS.includes(v)))];
return cleaned;
};
const allocateUniqueUsername = async (preferred) => {
let username = preferred && String(preferred).trim();
if (username) {
const exists = await User.exists({ username });
if (exists) throw new AppError('USER_ALREADY_EXISTS', null, 'Username already exists');
return username;
}
for (let attempt = 0; attempt < 12; attempt += 1) {
username = generateUsername();
const exists = await User.exists({ username });
if (!exists) return username;
}
throw new AppError('INTERNAL_SERVER_ERROR', null, 'Could not generate a unique username');
};
const signUp = async (body) => {
const { name, surname, nationalId, nationalIdCode, phoneNumber, phone, username, password, email, address, preferredMessenger } = body;
const userRole = await Role.findOne({ name: 'User' });
if (!userRole) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
const passwordHash = await bcrypt.hash(password, 10);
const user = await User.create({
name,
surname,
nationalIdCode: nationalIdCode || nationalId,
phoneNumber: phoneNumber || phone,
username,
passwordHash,
email,
address,
preferredMessenger: normalizePreferredMessengers(preferredMessenger),
role: userRole._id
});
return user.populate(POPULATE_ROLE);
};
const getUserById = async (id) => {
const user = await User.findById(id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean();
if (!user) throw new AppError('USER_NOT_FOUND');
return user;
};
const getAllUsers = async (query) => {
const page = parseInt(query.page) || 1;
const limit = Math.min(parseInt(query.limit) || 20, 200);
const skip = (page - 1) * limit;
const filter = {};
if (query.search) {
filter.$or = [
{ name: new RegExp(query.search, 'i') },
{ surname: new RegExp(query.search, 'i') },
{ username: new RegExp(query.search, 'i') }
];
}
if (query.isActive !== undefined) filter.isActive = query.isActive === 'true';
const [items, total] = await Promise.all([
User.find(filter).select(SAFE_FIELDS).populate(POPULATE_ROLE).skip(skip).limit(limit).sort({ createdAt: -1 }).lean(),
User.countDocuments(filter)
]);
return { data: items, meta: calculateMeta(total, page, limit) };
};
const searchUsers = async (query) => getAllUsers(query);
const createUserAdmin = async (body) => {
const {
name,
surname,
nationalIdCode,
nationalId,
phoneNumber,
phone,
username: requestedUsername,
password: requestedPassword,
email,
roleId,
role,
address,
preferredMessenger,
} = body;
let roleObj = null;
if (roleId || role) {
roleObj = await Role.findById(roleId || role);
}
if (!roleObj) {
roleObj = await Role.findOne({ name: 'User' });
}
if (!roleObj) throw new AppError('DEFAULT_ROLE_NOT_FOUND');
const plainPassword = (requestedPassword && String(requestedPassword).trim()) || generateSimplePassword();
const username = await allocateUniqueUsername(requestedUsername);
const passwordHash = await bcrypt.hash(plainPassword, 10);
const resolvedPhone = phoneNumber || phone;
const user = await User.create({
name,
surname,
nationalIdCode: nationalIdCode || nationalId,
phoneNumber: resolvedPhone,
username,
passwordHash,
email,
address,
preferredMessenger: normalizePreferredMessengers(preferredMessenger),
role: roleObj._id,
});
try {
await sendAccountCreatedSms(resolvedPhone, username, plainPassword);
} catch (err) {
logger.error(`[createUserAdmin] Account SMS failed for ${resolvedPhone}: ${err.message}`);
}
const created = await User.findById(user._id).select(SAFE_FIELDS).populate(POPULATE_ROLE).lean();
return {
...created,
generatedCredentials: {
username,
password: plainPassword,
},
};
};
const updateUser = async (id, body) => {
const {
password,
nationalId,
nationalIdCode,
phone,
phoneNumber,
roleId,
role,
username,
passwordHash,
refreshTokens,
preferredMessenger,
_id,
id: bodyId,
createdAt,
updatedAt,
__v,
...rest
} = body;
const update = { ...rest };
// Map frontend field aliases to schema fields
if (nationalIdCode || nationalId) {
update.nationalIdCode = nationalIdCode || nationalId;
}
if (phoneNumber || phone) {
update.phoneNumber = phoneNumber || phone;
}
if (roleId || role) {
update.role = roleId || role;
}
if (preferredMessenger !== undefined) {
update.preferredMessenger = normalizePreferredMessengers(preferredMessenger) || [];
}
// Never overwrite username with an empty value on update
if (typeof username === 'string' && username.trim()) {
update.username = username.trim();
}
// Strip empty strings so required validators are not tripped
Object.keys(update).forEach((key) => {
if (key === 'preferredMessenger') return;
if (update[key] === '' || update[key] === null || update[key] === undefined) {
delete update[key];
}
});
if (password) {
update.passwordHash = await bcrypt.hash(password, 10);
}
const user = await User.findByIdAndUpdate(id, update, { new: true, runValidators: true })
.select(SAFE_FIELDS)
.populate(POPULATE_ROLE)
.lean();
if (!user) throw new AppError('USER_NOT_FOUND');
return user;
};
const deleteUser = async (id) => {
const user = await User.findByIdAndDelete(id);
if (!user) throw new AppError('USER_NOT_FOUND');
};
const enrollUserInCourse = async (userId, courseId, actorId) => {
const user = await User.findById(userId);
if (!user) throw new AppError('USER_NOT_FOUND');
if (!user.courses.includes(courseId)) {
user.courses.push(courseId);
await user.save();
}
return User.findById(userId).select(SAFE_FIELDS).populate('courses').lean();
};
module.exports = { signUp, getUserById, getAllUsers, searchUsers, createUserAdmin, updateUser, deleteUser, enrollUserInCourse };
+6
View File
@@ -0,0 +1,6 @@
// Stub validator — pass-through middleware (no validation yet)
const passThrough = (req, res, next) => next();
module.exports = new Proxy({}, {
get: () => passThrough
});
+60
View File
@@ -0,0 +1,60 @@
// /config/config.js
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
const config = {
NODE_ENV: process.env.NODE_ENV || 'development',
PORT: parseInt(process.env.PORT, 10) || 3000,
MONGO_URI: process.env.MONGO_URI || 'mongodb://localhost:27017/teaching_institution_db',
JWT_ACCESS_SECRET: process.env.JWT_ACCESS_SECRET || 'default_super_secret_access_key_change_in_production',
JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || 'default_super_secret_refresh_key_change_in_production',
JWT_ACCESS_EXPIRES_IN: process.env.JWT_ACCESS_EXPIRES_IN || '15m',
JWT_REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
CORS_ORIGIN: process.env.CORS_ORIGIN || '*',
DEFAULT_LANG: process.env.DEFAULT_LANG || 'en',
UPLOAD_PATH: process.env.UPLOAD_PATH || 'uploads',
// S3 / MinIO Object Storage Settings
S3_ENDPOINT: process.env.S3_ENDPOINT || 'http://localhost:9000',
S3_REGION: process.env.S3_REGION || 'us-east-1',
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY || 'minioadmin',
S3_SECRET_KEY: process.env.S3_SECRET_KEY || 'minioadmin',
S3_TEMP_BUCKET: process.env.S3_TEMP_BUCKET || 'gameno-temp',
S3_STORAGE_BUCKET: process.env.S3_STORAGE_BUCKET || 'gameno-storage',
S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE === 'false' ? false : true,
SIGNED_URL_EXPIRES_IN: parseInt(process.env.SIGNED_URL_EXPIRES_IN, 10) || 900, // 15 minutes default
// SMTP Email Settings
SMTP_HOST: process.env.SMTP_HOST || 'smtp.mailtrap.io',
SMTP_PORT: parseInt(process.env.SMTP_PORT, 10) || 2525,
SMTP_USER: process.env.SMTP_USER || '',
SMTP_PASS: process.env.SMTP_PASS || '',
EMAIL_FROM: process.env.EMAIL_FROM || 'no-reply@institution.com',
// SMS Provider Settings (sms.ir)
SMS_ENABLED: process.env.SMS_ENABLED || 'false',
SMS_PANEL_TOKEN: process.env.SMS_PANEL_TOKEN || process.env.SMS_API_KEY || '',
SMS_API_KEY: process.env.SMS_PANEL_TOKEN || process.env.SMS_API_KEY || 'mock_sms_key',
SMS_SENDER_NUMBER: process.env.SMS_SENDER_NUMBER || '10001000',
// Template IDs configured in the sms.ir panel
SMS_TEMPLATE_ACCOUNT_CREATED: process.env.SMS_TEMPLATE_ACCOUNT_CREATED || '',
SMS_TEMPLATE_CLASS_REGISTERED: process.env.SMS_TEMPLATE_CLASS_REGISTERED || '',
SMS_TEMPLATE_CLASS_REMINDER: process.env.SMS_TEMPLATE_CLASS_REMINDER || '',
// Bale Messenger Bot Settings
BALE_BOT_TOKEN: process.env.BALE_BOT_TOKEN || 'mock_bale_bot_token',
// SuperAdmin Seed Settings
SUPERADMIN_USERNAME: process.env.SUPERADMIN_USERNAME || 'superadmin',
SUPERADMIN_PASSWORD: process.env.SUPERADMIN_PASSWORD || 'SuperAdminSecret123!',
SUPERADMIN_EMAIL: process.env.SUPERADMIN_EMAIL || 'admin@institution.com',
SUPERADMIN_NATIONAL_ID: process.env.SUPERADMIN_NATIONAL_ID || '0000000000',
SUPERADMIN_PHONE: process.env.SUPERADMIN_PHONE || '09000000000'
};
module.exports = config;
+27
View File
@@ -0,0 +1,27 @@
// /config/db.js
const mongoose = require('mongoose');
const config = require('./config');
const logger = require('../utils/logger');
const connectDB = async () => {
try {
const conn = await mongoose.connect(config.MONGO_URI, {
autoIndex: true
});
logger.info(`MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
logger.error(`Database connection error: ${error.message}`);
process.exit(1);
}
};
mongoose.connection.on('disconnected', () => {
logger.warn('MongoDB disconnected. Attempting to reconnect...');
});
mongoose.connection.on('error', (err) => {
logger.error(`MongoDB connection error: ${err.message}`);
});
module.exports = connectDB;
+33
View File
@@ -0,0 +1,33 @@
// /constants/eventNames.js
const EVENT_NAMES = {
USER_REGISTERED: 'user.registered',
USER_CREATED: 'user.created',
USER_ENROLLED: 'user.enrolled',
USER_UPDATED: 'user.updated',
PROFESSOR_CREATED: 'professor.created',
COURSE_CREATED: 'course.created',
COURSE_PRICE_CHANGED: 'course.price_changed',
SESSION_CREATED: 'session.created',
SESSION_CANCELLED: 'session.cancelled',
ATTENDANCE_RECORDED: 'attendance.recorded',
ATTENDANCE_BULK_RECORDED: 'attendance.bulk_recorded',
PAYMENT_CREATED: 'payment.created',
PAYMENT_STATUS_CHANGED: 'payment.status_changed',
PAYMENT_TRANSACTION_ADDED: 'payment.transaction_added',
PAYMENT_REMINDER_DUE: 'payment.reminder_due',
NOTIFICATION_CREATED: 'notification.created',
NOTIFICATION_SENT: 'notification.sent',
NOTIFICATION_FAILED: 'notification.failed',
CERTIFICATE_ISSUED: 'certificate.issued',
CERTIFICATE_UPLOADED: 'certificate.uploaded'
};
module.exports = EVENT_NAMES;
+90
View File
@@ -0,0 +1,90 @@
// /constants/permissions.js
const PERMISSIONS = {
// Users permissions
USERS_CREATE: 'users:create',
USERS_READ: 'users:read',
USERS_UPDATE: 'users:update',
USERS_DELETE: 'users:delete',
USERS_SEARCH: 'users:search',
USERS_ENROLL: 'users:enroll',
// Professors permissions
PROFESSORS_CREATE: 'professors:create',
PROFESSORS_READ: 'professors:read',
PROFESSORS_UPDATE: 'professors:update',
PROFESSORS_DELETE: 'professors:delete',
PROFESSORS_SEARCH: 'professors:search',
// Courses permissions
COURSES_CREATE: 'courses:create',
COURSES_READ: 'courses:read',
COURSES_UPDATE: 'courses:update',
COURSES_DELETE: 'courses:delete',
COURSES_SEARCH: 'courses:search',
// Classes permissions
CLASSES_CREATE: 'classes:create',
CLASSES_READ: 'classes:read',
CLASSES_UPDATE: 'classes:update',
CLASSES_DELETE: 'classes:delete',
CLASSES_SEARCH: 'classes:search',
CLASSES_REGISTER_USERS: 'classes:register_users',
// Sessions permissions
SESSIONS_CREATE: 'sessions:create',
SESSIONS_READ: 'sessions:read',
SESSIONS_UPDATE: 'sessions:update',
SESSIONS_DELETE: 'sessions:delete',
SESSIONS_SEARCH: 'sessions:search',
SESSIONS_ATTENDANCE: 'sessions:attendance',
// Payments permissions
PAYMENTS_CREATE: 'payments:create',
PAYMENTS_READ: 'payments:read',
PAYMENTS_UPDATE: 'payments:update',
PAYMENTS_DELETE: 'payments:delete',
PAYMENTS_SEARCH: 'payments:search',
// Roles permissions
ROLES_CREATE: 'roles:create',
ROLES_READ: 'roles:read',
ROLES_UPDATE: 'roles:update',
ROLES_DELETE: 'roles:delete',
ROLES_SEARCH: 'roles:search',
// Notifications permissions
NOTIFICATIONS_CREATE: 'notifications:create',
NOTIFICATIONS_READ: 'notifications:read',
NOTIFICATIONS_UPDATE: 'notifications:update',
NOTIFICATIONS_DELETE: 'notifications:delete',
NOTIFICATIONS_SEARCH: 'notifications:search',
NOTIFICATIONS_RETRY: 'notifications:retry',
// Certificates permissions
CERTIFICATES_CREATE: 'certificates:create',
CERTIFICATES_READ: 'certificates:read',
CERTIFICATES_UPDATE: 'certificates:update',
CERTIFICATES_DELETE: 'certificates:delete',
CERTIFICATES_SEARCH: 'certificates:search',
// Files permissions
FILES_UPLOAD: 'files:upload',
FILES_READ: 'files:read',
FILES_DELETE: 'files:delete',
// Activity logs permissions
LOGS_READ: 'logs:read',
LOGS_SEARCH: 'logs:search',
// Contact inquiries (website form)
CONTACT_INQUIRIES_READ: 'contact_inquiries:read',
CONTACT_INQUIRIES_UPDATE: 'contact_inquiries:update'
};
const ALL_PERMISSIONS = Object.values(PERMISSIONS);
module.exports = {
PERMISSIONS,
ALL_PERMISSIONS
};
+9
View File
@@ -0,0 +1,9 @@
// /events/eventEmitter.js
const EventEmitter = require('events');
class DomainEventEmitter extends EventEmitter {}
const eventEmitter = new DomainEventEmitter();
module.exports = eventEmitter;
+154
View File
@@ -0,0 +1,154 @@
// /events/eventListeners.js
const eventEmitter = require('./eventEmitter');
const EVENT_NAMES = require('../constants/eventNames');
const EventLog = require('./eventLogModel');
const Notification = require('../components/notifications/notificationModel');
const User = require('../components/users/userModel');
const Course = require('../components/courses/courseModel');
const logger = require('../utils/logger');
const { sendEmail } = require('../utils/senders/emailSender');
const { sendSMS } = require('../utils/senders/smsSender');
const { sendBaleMessage } = require('../utils/senders/baleBotSender');
const safeEventListener = (handler) => {
return async (payload) => {
try {
await handler(payload);
} catch (error) {
logger.error(`[EventListener Error] Handler failure: ${error.message}`, { stack: error.stack });
}
};
};
const recordEventLog = async (eventName, payload, actor = null) => {
try {
await EventLog.create({
eventName,
payload,
actor: actor || payload?.actorId || payload?.userId || null
});
} catch (err) {
logger.error(`[EventLog Error] Failed to write audit log: ${err.message}`);
}
};
const dispatchNotification = async ({ userId, preferredChannel, subject, body, relatedEvent }) => {
try {
const user = await User.findById(userId);
if (!user) return;
const channelMap = {
Email: 'email',
SMS: 'sms',
Bale: 'baleBot',
WhatsApp: 'sms',
Telegram: 'baleBot'
};
const preferred = Array.isArray(user.preferredMessenger)
? user.preferredMessenger[0]
: user.preferredMessenger;
const channel = channelMap[preferred || preferredChannel] || 'sms';
const notification = await Notification.create({
user: userId,
channel,
subject: subject || 'Institution Notification',
body,
status: 'pending',
relatedEvent
});
let sendResult;
try {
if (channel === 'email' && user.email) {
sendResult = await sendEmail({ to: user.email, subject, body });
} else if (channel === 'baleBot') {
sendResult = await sendBaleMessage({ chatId: user.phoneNumber, body });
} else {
sendResult = await sendSMS({ phoneNumber: user.phoneNumber, body });
}
notification.status = 'sent';
notification.sentAt = new Date();
await notification.save();
eventEmitter.emit(EVENT_NAMES.NOTIFICATION_SENT, { notificationId: notification._id });
} catch (sendErr) {
notification.status = 'failed';
notification.lastError = sendErr.message;
notification.retryCount = 1;
await notification.save();
eventEmitter.emit(EVENT_NAMES.NOTIFICATION_FAILED, { notificationId: notification._id, error: sendErr.message });
}
} catch (error) {
logger.error(`[DispatchNotification Error]: ${error.message}`);
}
};
const registerEventListeners = () => {
// Listener for USER_ENROLLED
eventEmitter.on(EVENT_NAMES.USER_ENROLLED, safeEventListener(async (payload) => {
const { userId, courseId, actorId } = payload;
await recordEventLog(EVENT_NAMES.USER_ENROLLED, payload, actorId);
const course = await Course.findById(courseId);
const courseTitle = course ? course.title : 'Course';
await dispatchNotification({
userId,
subject: 'Course Enrollment Confirmation',
body: `You have been successfully enrolled in ${courseTitle}.`,
relatedEvent: EVENT_NAMES.USER_ENROLLED
});
}));
// Listener for PAYMENT_STATUS_CHANGED
eventEmitter.on(EVENT_NAMES.PAYMENT_STATUS_CHANGED, safeEventListener(async (payload) => {
const { paymentId, userId, newStatus, actorId } = payload;
await recordEventLog(EVENT_NAMES.PAYMENT_STATUS_CHANGED, payload, actorId);
await dispatchNotification({
userId,
subject: 'Payment Status Update',
body: `Your payment status has been updated to: ${newStatus}.`,
relatedEvent: EVENT_NAMES.PAYMENT_STATUS_CHANGED
});
}));
// Listener for COURSE_PRICE_CHANGED
eventEmitter.on(EVENT_NAMES.COURSE_PRICE_CHANGED, safeEventListener(async (payload) => {
const { courseId, oldPrice, newPrice, actorId } = payload;
await recordEventLog(EVENT_NAMES.COURSE_PRICE_CHANGED, payload, actorId);
logger.info(`[Event] Course ${courseId} price changed from ${oldPrice} to ${newPrice}`);
}));
// Listener for SESSION_CANCELLED
eventEmitter.on(EVENT_NAMES.SESSION_CANCELLED, safeEventListener(async (payload) => {
const { sessionId, courseId, topic, actorId } = payload;
await recordEventLog(EVENT_NAMES.SESSION_CANCELLED, payload, actorId);
const enrolledUsers = await User.find({ courses: courseId });
for (const student of enrolledUsers) {
await dispatchNotification({
userId: student._id,
subject: 'Session Cancellation Notice',
body: `The session for topic "${topic || 'Upcoming Class'}" has been cancelled.`,
relatedEvent: EVENT_NAMES.SESSION_CANCELLED
});
}
}));
// Generic audit logger for all other events
Object.values(EVENT_NAMES).forEach(eventName => {
if (![EVENT_NAMES.USER_ENROLLED, EVENT_NAMES.PAYMENT_STATUS_CHANGED, EVENT_NAMES.COURSE_PRICE_CHANGED, EVENT_NAMES.SESSION_CANCELLED].includes(eventName)) {
eventEmitter.on(eventName, safeEventListener(async (payload) => {
await recordEventLog(eventName, payload);
}));
}
});
logger.info('Domain event listeners successfully registered.');
};
module.exports = registerEventListeners;
+25
View File
@@ -0,0 +1,25 @@
// /events/eventLogModel.js
const mongoose = require('mongoose');
const eventLogSchema = new mongoose.Schema({
eventName: {
type: String,
required: true,
index: true
},
payload: {
type: mongoose.Schema.Types.Mixed
},
actor: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
default: null
},
createdAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('EventLog', eventLogSchema);
+95
View File
@@ -0,0 +1,95 @@
// /jobs/classReminderJob.js
'use strict';
const cron = require('node-cron');
const Session = require('../components/sessions/sessionModel');
const User = require('../components/users/userModel');
const { sendClassReminderSms } = require('../utils/senders/smsMessages');
const logger = require('../utils/logger');
const parseStartDateTime = (session) => {
const day = new Date(session.day);
if (Number.isNaN(day.getTime())) return null;
const [hoursRaw, minutesRaw = '0'] = String(session.startTime || '00:00').split(':');
const hours = Number(hoursRaw);
const minutes = Number(minutesRaw);
if (Number.isNaN(hours) || Number.isNaN(minutes)) return null;
const start = new Date(day);
start.setHours(hours, minutes, 0, 0);
return start;
};
const runClassReminderJob = async () => {
try {
const now = Date.now();
const windowStart = new Date(now + 29 * 60 * 1000);
const windowEnd = new Date(now + 31 * 60 * 1000);
// Load scheduled sessions for today ± 1 day, then filter by exact start window
const dayFrom = new Date(now - 24 * 60 * 60 * 1000);
const dayTo = new Date(now + 24 * 60 * 60 * 1000);
const sessions = await Session.find({
status: 'scheduled',
reminderSentAt: null,
day: { $gte: dayFrom, $lte: dayTo },
})
.populate({ path: 'class', select: 'name students' })
.populate({ path: 'course', select: 'title' })
.lean();
for (const session of sessions) {
const startAt = parseStartDateTime(session);
if (!startAt || startAt < windowStart || startAt > windowEnd) continue;
const classDoc = session.class;
const studentIds = classDoc?.students || [];
if (studentIds.length === 0) {
await Session.updateOne({ _id: session._id }, { reminderSentAt: new Date() });
continue;
}
const classLabel = classDoc?.name || session.course?.title || 'کلاس';
const timeLabel = session.startTime;
const placeLabel = session.place || '-';
const users = await User.find({ _id: { $in: studentIds } }).select('phoneNumber').lean();
await Promise.all(
users.map(async (user) => {
if (!user.phoneNumber) return;
try {
await sendClassReminderSms(
user.phoneNumber,
classLabel,
timeLabel,
placeLabel,
user._id
);
} catch (err) {
logger.error(`[ClassReminderJob] SMS failed for ${user.phoneNumber}: ${err.message}`);
}
})
);
await Session.updateOne({ _id: session._id }, { reminderSentAt: new Date() });
logger.info(`[ClassReminderJob] Reminders sent for session ${session._id}`);
}
} catch (error) {
logger.error(`[ClassReminderJob ERROR]: ${error.message}`);
}
};
const startClassReminderJob = () => {
// Every minute — catches sessions ~30 minutes before start
cron.schedule('* * * * *', async () => {
await runClassReminderJob();
});
logger.info('[ClassReminderJob] Scheduled to run every minute.');
};
module.exports = {
startClassReminderJob,
runClassReminderJob,
};
+45
View File
@@ -0,0 +1,45 @@
// /jobs/notificationRetryJob.js
const cron = require('node-cron');
const Notification = require('../components/notifications/notificationModel');
const { retryNotification } = require('../components/notifications/notificationService');
const logger = require('../utils/logger');
const runNotificationRetryJob = async () => {
try {
const failedNotifications = await Notification.find({
status: 'failed',
$expr: { $lt: ['$retryCount', '$maxRetries'] }
}).limit(50);
if (failedNotifications.length === 0) {
return;
}
logger.info(`[NotificationRetryJob] Found ${failedNotifications.length} failed notifications to retry...`);
for (const notification of failedNotifications) {
try {
await retryNotification(notification._id);
logger.info(`[NotificationRetryJob] Successfully retried notification ID: ${notification._id}`);
} catch (retryError) {
logger.warn(`[NotificationRetryJob] Retry failed for notification ID ${notification._id}: ${retryError.message}`);
}
}
} catch (error) {
logger.error(`[NotificationRetryJob ERROR]: ${error.message}`);
}
};
const startNotificationRetryJob = () => {
// Run every 5 minutes
cron.schedule('*/5 * * * *', async () => {
await runNotificationRetryJob();
});
logger.info('[NotificationRetryJob] Scheduled to run every 5 minutes.');
};
module.exports = {
startNotificationRetryJob,
runNotificationRetryJob
};
+66
View File
@@ -0,0 +1,66 @@
// /jobs/paymentReminderJob.js
const cron = require('node-cron');
const Payment = require('../components/payments/paymentModel');
const eventEmitter = require('../events/eventEmitter');
const EVENT_NAMES = require('../constants/eventNames');
const logger = require('../utils/logger');
const runPaymentReminderJob = async () => {
try {
const now = new Date();
const threeDaysFromNow = new Date();
threeDaysFromNow.setDate(now.getDate() + 3);
// Find pending/partiallyPaid payments due in the next 3 days
const upcomingPayments = await Payment.find({
status: { $in: ['pending', 'partiallyPaid'] },
dueDate: { $gte: now, $lte: threeDaysFromNow }
}).populate('user');
for (const payment of upcomingPayments) {
if (payment.user) {
eventEmitter.emit(EVENT_NAMES.PAYMENT_REMINDER_DUE, {
paymentId: payment._id,
userId: payment.user._id,
amountDue: payment.amount - payment.amountPaid,
dueDate: payment.dueDate
});
}
}
// Find overdue payments and update status to overdue
const overduePayments = await Payment.find({
status: { $in: ['pending', 'partiallyPaid'] },
dueDate: { $lt: now }
});
for (const payment of overduePayments) {
payment.status = 'overdue';
await payment.save();
if (payment.user) {
eventEmitter.emit(EVENT_NAMES.PAYMENT_STATUS_CHANGED, {
paymentId: payment._id,
userId: payment.user,
oldStatus: 'pending',
newStatus: 'overdue'
});
}
}
} catch (error) {
logger.error(`[PaymentReminderJob ERROR]: ${error.message}`);
}
};
const startPaymentReminderJob = () => {
// Run daily at midnight
cron.schedule('0 0 * * *', async () => {
await runPaymentReminderJob();
});
logger.info('[PaymentReminderJob] Scheduled to run daily at midnight.');
};
module.exports = {
startPaymentReminderJob,
runPaymentReminderJob
};
+28
View File
@@ -0,0 +1,28 @@
// /jobs/tempBucketCleanupJob.js
const cron = require('node-cron');
const { cleanupTempBucket } = require('../utils/s3Client');
const logger = require('../utils/logger');
const runTempBucketCleanupJob = async () => {
try {
logger.info('[TempBucketCleanupJob] Starting daily temp bucket cleanup...');
const deletedCount = await cleanupTempBucket(10); // Files older than 10 minutes
logger.info(`[TempBucketCleanupJob] Daily cleanup finished. Removed ${deletedCount} files.`);
} catch (error) {
logger.error(`[TempBucketCleanupJob ERROR]: ${error.message}`);
}
};
const startTempBucketCleanupJob = () => {
// Schedule daily at 3:00 AM
cron.schedule('0 3 * * *', async () => {
await runTempBucketCleanupJob();
});
logger.info('[TempBucketCleanupJob] Scheduled to run daily at 3:00 AM.');
};
module.exports = {
startTempBucketCleanupJob,
runTempBucketCleanupJob
};
+104
View File
@@ -0,0 +1,104 @@
// /middlewares/activityLogger.js
'use strict';
const activityLogService = require('../components/activityLogs/activityLogService');
const logger = require('../utils/logger');
const SKIP_PREFIXES = [
'/api/health',
'/api/activity-logs'
];
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const resolveResource = (path = '') => {
const clean = path.split('?')[0].replace(/^\/api\//, '');
const [resource] = clean.split('/').filter(Boolean);
return resource || 'unknown';
};
const resolveResourceId = (req) => {
if (req.params?.id) return String(req.params.id);
if (req.params?.userId) return String(req.params.userId);
if (req.body?._id) return String(req.body._id);
if (req.body?.id) return String(req.body.id);
return null;
};
const resolveAction = (method, path = '') => {
const p = path.toLowerCase();
if (p.includes('/auth/login')) return 'login';
if (p.includes('/auth/logout')) return 'logout';
if (p.includes('/auth/refresh')) return 'other';
if (p.includes('enroll') || p.includes('register')) return 'enroll';
if (p.includes('attendance')) return 'attendance';
if (p.includes('upload')) return 'upload';
if (p.includes('retry')) return 'retry';
switch (method) {
case 'POST':
return 'create';
case 'PUT':
case 'PATCH':
return 'update';
case 'DELETE':
return 'delete';
default:
return 'other';
}
};
const shouldSkip = (req) => {
if (!MUTATING_METHODS.has(req.method)) return true;
const path = req.originalUrl || req.url || '';
if (SKIP_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;
if (path.includes('/auth/refresh')) return true;
return false;
};
const activityLogger = (req, res, next) => {
if (shouldSkip(req)) {
return next();
}
res.on('finish', () => {
// Only persist completed mutating requests (success or business failure)
if (res.statusCode < 200 || res.statusCode >= 600) return;
const path = (req.originalUrl || req.url || '').split('?')[0];
const action = resolveAction(req.method, path);
const actor = req.user || null;
const payload = {
actor: actor?._id || null,
actorUsername: actor?.username || req.body?.username || null,
actorName: actor
? `${actor.name || ''} ${actor.surname || ''}`.trim() || actor.username
: req.body?.username || null,
action,
resource: resolveResource(path),
resourceId: resolveResourceId(req),
method: req.method,
path,
statusCode: res.statusCode,
ip: req.ip || req.headers['x-forwarded-for'] || req.connection?.remoteAddress || null,
userAgent: req.headers['user-agent'] || null,
metadata: {
params: activityLogService.sanitizeValue(req.params || {}),
query: activityLogService.sanitizeValue(req.query || {}),
body: activityLogService.sanitizeValue(req.body || {})
}
};
activityLogService.createActivityLog(payload).catch((err) => {
logger.error(`[ActivityLog] Failed to write log: ${err.message}`);
});
});
next();
};
module.exports = activityLogger;
+43
View File
@@ -0,0 +1,43 @@
// /middlewares/authMiddleware.js
const jwt = require('jsonwebtoken');
const config = require('../config/config');
const AppError = require('../utils/AppError');
const User = require('../components/users/userModel');
const authMiddleware = async (req, res, next) => {
try {
let token = null;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.split(' ')[1];
}
if (!token) {
return next(new AppError('UNAUTHORIZED'));
}
let decoded;
try {
decoded = jwt.verify(token, config.JWT_ACCESS_SECRET);
} catch (err) {
if (err.name === 'TokenExpiredError') {
return next(new AppError('TOKEN_EXPIRED'));
}
return next(new AppError('UNAUTHORIZED'));
}
const user = await User.findById(decoded.id).populate('role');
if (!user || !user.isActive) {
return next(new AppError('UNAUTHORIZED'));
}
req.user = user;
next();
} catch (error) {
next(error);
}
};
module.exports = authMiddleware;
+107
View File
@@ -0,0 +1,107 @@
// /middlewares/globalErrorHandler.js
const logger = require('../utils/logger');
const { errorResponse } = require('../utils/apiResponse');
const { getLocalizedErrorMessage } = require('../utils/errorLocalization');
const formatDetailsMessage = (details) => {
if (!details) return null;
if (typeof details === 'string') return details;
if (Array.isArray(details)) {
const parts = details
.map((item) => {
if (!item) return null;
if (typeof item === 'string') return item;
return item.message || item.reason || null;
})
.filter(Boolean);
return parts.length ? parts.join(' — ') : null;
}
if (typeof details === 'object') {
const parts = Object.entries(details).map(([key, value]) => {
if (value == null) return null;
if (typeof value === 'string') return `${key}: ${value}`;
if (typeof value === 'object' && value.message) return `${key}: ${value.message}`;
if (key === 'keyValue') return Object.entries(value).map(([k, v]) => `${k}=${v}`).join(', ');
return null;
}).filter(Boolean);
return parts.length ? parts.join(' — ') : null;
}
return null;
};
const globalErrorHandler = (err, req, res, next) => {
let statusCode = err.statusCode || 500;
let errorCode = err.errorCode || 'INTERNAL_SERVER_ERROR';
let details = err.details || null;
// Handle Mongoose CastError (e.g. invalid ObjectId)
if (err.name === 'CastError') {
statusCode = 400;
errorCode = 'VALIDATION_FAILED';
details = { field: err.path, value: err.value, reason: 'Invalid ObjectId format' };
}
// Handle Mongoose Duplicate Key Error (E11000)
if (err.code === 11000) {
statusCode = 409;
errorCode = 'DUPLICATE_KEY';
details = { keyPattern: err.keyPattern, keyValue: err.keyValue };
}
// Handle Mongoose ValidationError
if (err.name === 'ValidationError') {
statusCode = 400;
errorCode = 'VALIDATION_FAILED';
details = Object.keys(err.errors || {}).reduce((acc, key) => {
acc[key] = err.errors[key].message;
return acc;
}, {});
}
// Handle Joi validation errors if forwarded as standard Error
if (err.isJoi) {
statusCode = 400;
errorCode = 'VALIDATION_FAILED';
details = err.details
? err.details.map((d) => ({ message: d.message, path: d.path }))
: null;
}
// Localized catalog message (Accept-Language / ?lang=)
let localizedMessage = err.overrideMessage
? err.overrideMessage
: getLocalizedErrorMessage(errorCode, req);
const detailsMessage = formatDetailsMessage(details);
if (detailsMessage && errorCode === 'VALIDATION_FAILED' && !err.overrideMessage) {
localizedMessage = `${localizedMessage} ${detailsMessage}`;
} else if (detailsMessage && errorCode === 'DUPLICATE_KEY') {
localizedMessage = `${localizedMessage} (${detailsMessage})`;
}
// Log error (stack trace for non-operational / 500 errors)
if (!err.isOperational || statusCode >= 500) {
logger.error(`[PROGRAMMING_ERROR] ${err.message}`, {
stack: err.stack,
path: req.originalUrl,
method: req.method
});
} else {
logger.warn(`[OPERATIONAL_ERROR] Code: ${errorCode} | Path: ${req.originalUrl} | Message: ${localizedMessage}`);
}
return errorResponse(
res,
statusCode,
errorCode,
localizedMessage,
details
);
};
module.exports = globalErrorHandler;
+9
View File
@@ -0,0 +1,9 @@
// /middlewares/notFoundHandler.js
const AppError = require('../utils/AppError');
const notFoundHandler = (req, res, next) => {
next(new AppError('NOT_FOUND', { path: req.originalUrl, method: req.method }));
};
module.exports = notFoundHandler;
+35
View File
@@ -0,0 +1,35 @@
// /middlewares/permissionMiddleware.js
const AppError = require('../utils/AppError');
const requirePermission = (requiredPermission) => {
return (req, res, next) => {
try {
if (!req.user) {
return next(new AppError('UNAUTHORIZED'));
}
const role = req.user.role;
if (!role) {
return next(new AppError('FORBIDDEN'));
}
// Check if user is superAdmin (by role name or system status)
if (role.name === 'superAdmin' || (role.isSystem && role.permissions.includes('*'))) {
return next();
}
// Check if required permission is held by role
if (Array.isArray(role.permissions) && role.permissions.includes(requiredPermission)) {
return next();
}
return next(new AppError('FORBIDDEN'));
} catch (error) {
next(error);
}
};
};
module.exports = requirePermission;
module.exports.requires = requirePermission;
+3199
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "gameno-api",
"version": "1.0.0",
"description": "Teaching Institution Management Dashboard Backend API",
"main": "app.js",
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js",
"seed": "node seed.js"
},
"keywords": [
"express",
"mongoose",
"node",
"rbac",
"jwt",
"teaching-institution"
],
"author": "",
"license": "ISC",
"dependencies": {
"@aws-sdk/client-s3": "^3.1106.0",
"@aws-sdk/s3-request-presigner": "^3.1106.0",
"axios": "^1.7.9",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.0.0",
"joi": "^17.13.3",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.9.5",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"nodemailer": "^6.10.0",
"winston": "^3.17.0"
},
"devDependencies": {
"nodemon": "^3.1.9"
}
}
+144
View File
@@ -0,0 +1,144 @@
// /seed.js
const mongoose = require('mongoose');
const config = require('./config/config');
const Role = require('./components/roles/roleModel');
const User = require('./components/users/userModel');
const { ALL_PERMISSIONS, PERMISSIONS } = require('./constants/permissions');
const bcrypt = require('bcryptjs');
const defaultRoles = [
{
name: 'SuperAdmin',
description: 'مدیر ارشد سیستم با دسترسی کامل به تمام بخش‌ها',
permissions: ALL_PERMISSIONS,
isSystem: true
},
{
name: 'Admin',
description: 'مدیر سیستم',
permissions: ALL_PERMISSIONS.filter(p => !p.startsWith('roles:')),
isSystem: true
},
{
name: 'Secretary',
description: 'منشی و مسئول ثبت‌نام، امور دانشجویان و پرداخت‌ها',
permissions: [
PERMISSIONS.USERS_CREATE,
PERMISSIONS.USERS_READ,
PERMISSIONS.USERS_UPDATE,
PERMISSIONS.USERS_SEARCH,
PERMISSIONS.USERS_ENROLL,
PERMISSIONS.CLASSES_READ,
PERMISSIONS.CLASSES_SEARCH,
PERMISSIONS.CLASSES_REGISTER_USERS,
PERMISSIONS.COURSES_READ,
PERMISSIONS.COURSES_SEARCH,
PERMISSIONS.SESSIONS_READ,
PERMISSIONS.SESSIONS_SEARCH,
PERMISSIONS.SESSIONS_ATTENDANCE,
PERMISSIONS.PAYMENTS_CREATE,
PERMISSIONS.PAYMENTS_READ,
PERMISSIONS.PAYMENTS_UPDATE,
PERMISSIONS.PAYMENTS_SEARCH,
PERMISSIONS.NOTIFICATIONS_CREATE,
PERMISSIONS.NOTIFICATIONS_READ,
PERMISSIONS.NOTIFICATIONS_SEARCH,
PERMISSIONS.NOTIFICATIONS_RETRY,
PERMISSIONS.CERTIFICATES_CREATE,
PERMISSIONS.CERTIFICATES_READ,
PERMISSIONS.CERTIFICATES_SEARCH,
PERMISSIONS.FILES_UPLOAD,
PERMISSIONS.FILES_READ,
PERMISSIONS.LOGS_READ,
PERMISSIONS.LOGS_SEARCH,
PERMISSIONS.CONTACT_INQUIRIES_READ,
PERMISSIONS.CONTACT_INQUIRIES_UPDATE
],
isSystem: true
},
{
name: 'User',
description: 'کاربر معمولی و دانشجو',
permissions: [
PERMISSIONS.COURSES_READ,
PERMISSIONS.COURSES_SEARCH,
PERMISSIONS.CLASSES_READ,
PERMISSIONS.CLASSES_SEARCH,
PERMISSIONS.SESSIONS_READ,
PERMISSIONS.SESSIONS_SEARCH,
PERMISSIONS.PAYMENTS_READ,
PERMISSIONS.NOTIFICATIONS_READ,
PERMISSIONS.CERTIFICATES_READ,
PERMISSIONS.FILES_READ
],
isSystem: true
},
{
name: 'Professor',
description: 'استاد مدرس دوره',
permissions: [
PERMISSIONS.COURSES_READ,
PERMISSIONS.COURSES_SEARCH,
PERMISSIONS.CLASSES_READ,
PERMISSIONS.CLASSES_SEARCH,
PERMISSIONS.SESSIONS_READ,
PERMISSIONS.SESSIONS_SEARCH,
PERMISSIONS.SESSIONS_ATTENDANCE,
PERMISSIONS.NOTIFICATIONS_READ,
PERMISSIONS.FILES_READ
],
isSystem: true
}
];
const seedDatabase = async () => {
try {
console.log('Connecting to MongoDB...');
await mongoose.connect(config.MONGO_URI);
console.log('Connected to MongoDB successfully.');
for (const roleDef of defaultRoles) {
const existing = await Role.findOne({ name: roleDef.name });
if (existing) {
existing.description = roleDef.description;
existing.permissions = roleDef.permissions;
existing.isSystem = roleDef.isSystem;
await existing.save();
console.log(`Updated existing role: ${roleDef.name}`);
} else {
await Role.create(roleDef);
console.log(`Created new role: ${roleDef.name}`);
}
}
// Ensure SuperAdmin user exists
const superAdminRole = await Role.findOne({ name: 'SuperAdmin' });
if (superAdminRole) {
const adminUser = await User.findOne({ username: config.SUPERADMIN_USERNAME });
if (!adminUser) {
const passwordHash = await bcrypt.hash(config.SUPERADMIN_PASSWORD, 10);
await User.create({
name: 'مدیر',
surname: 'ارشد',
nationalId: config.SUPERADMIN_NATIONAL_ID,
phone: config.SUPERADMIN_PHONE,
email: config.SUPERADMIN_EMAIL,
username: config.SUPERADMIN_USERNAME,
passwordHash,
role: superAdminRole._id,
isActive: true
});
console.log(`SuperAdmin user created (${config.SUPERADMIN_USERNAME}).`);
}
}
console.log('Database seeding completed successfully.');
} catch (error) {
console.error('Database seeding failed:', error);
} finally {
await mongoose.disconnect();
console.log('Disconnected from MongoDB.');
}
};
seedDatabase();
+23
View File
@@ -0,0 +1,23 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
message: 'Gameno API is running',
timestamp: new Date().toISOString()
});
});
app.listen(PORT, () => {
console.log(`Gameno API server listening on http://localhost:${PORT}`);
});
+22
View File
@@ -0,0 +1,22 @@
// /utils/AppError.js
const errors = require('./errors.json');
class AppError extends Error {
constructor(errorCode, details = null, overrideMessage = null) {
const errorDef = errors[errorCode] || errors.INTERNAL_SERVER_ERROR;
const message = overrideMessage || errorDef.en;
super(message);
this.name = 'AppError';
this.errorCode = errorCode in errors ? errorCode : 'INTERNAL_SERVER_ERROR';
this.statusCode = errorDef.statusCode || 500;
this.details = details;
this.overrideMessage = overrideMessage;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
+42
View File
@@ -0,0 +1,42 @@
// /utils/apiResponse.js
const successResponse = (res, statusCode = 200, message = 'Operation successful', data = null) => {
const response = {
success: true,
message
};
if (data !== null) {
response.data = data;
}
return res.status(statusCode).json(response);
};
const listResponse = (res, statusCode = 200, data = [], meta = {}) => {
return res.status(statusCode).json({
success: true,
data,
meta: {
totalCount: meta.totalCount || 0,
totalPages: meta.totalPages || 0,
currentPage: meta.currentPage || 1,
limit: meta.limit || 20
}
});
};
const errorResponse = (res, statusCode = 500, code = 'INTERNAL_SERVER_ERROR', message = 'An error occurred', details = {}) => {
return res.status(statusCode).json({
success: false,
error: {
code,
message,
details: details || {}
}
});
};
module.exports = {
successResponse,
listResponse,
errorResponse
};
+9
View File
@@ -0,0 +1,9 @@
// /utils/catchAsync.js
const catchAsync = (fn) => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
module.exports = catchAsync;
+27
View File
@@ -0,0 +1,27 @@
// /utils/credentials.js
'use strict';
const crypto = require('crypto');
const LETTERS = 'abcdefghijkmnpqrstuvwxyz';
const DIGITS = '23456789';
const randomFrom = (alphabet, length) => {
let out = '';
const bytes = crypto.randomBytes(length);
for (let i = 0; i < length; i += 1) {
out += alphabet[bytes[i] % alphabet.length];
}
return out;
};
/** Simple password: exactly 2 English letters + 4 digits (e.g. kx4821) */
const generateSimplePassword = () => `${randomFrom(LETTERS, 2)}${randomFrom(DIGITS, 4)}`;
/** Random username: u + 6 digits (e.g. u482910) */
const generateUsername = () => `u${randomFrom(DIGITS, 6)}`;
module.exports = {
generateSimplePassword,
generateUsername,
};
+28
View File
@@ -0,0 +1,28 @@
// /utils/errorLocalization.js
const errors = require('./errors.json');
const config = require('../config/config');
const resolveLanguage = (req) => {
if (req && req.query && req.query.lang) {
const lang = req.query.lang.toLowerCase();
if (['fa', 'en'].includes(lang)) return lang;
}
if (req && req.headers && req.headers['accept-language']) {
const headerLang = req.headers['accept-language'].toLowerCase();
if (headerLang.includes('fa')) return 'fa';
if (headerLang.includes('en')) return 'en';
}
return config.DEFAULT_LANG || 'en';
};
const getLocalizedErrorMessage = (errorCode, req = null) => {
const lang = resolveLanguage(req);
const errorDef = errors[errorCode] || errors.INTERNAL_SERVER_ERROR;
return errorDef[lang] || errorDef.en || 'An unexpected error occurred.';
};
module.exports = {
resolveLanguage,
getLocalizedErrorMessage
};
+142
View File
@@ -0,0 +1,142 @@
{
"USER_NOT_FOUND": {
"statusCode": 404,
"en": "User not found.",
"fa": "کاربر یافت نشد."
},
"USER_ALREADY_EXISTS": {
"statusCode": 409,
"en": "User with this national ID, phone number, or email already exists.",
"fa": "کاربری با این کدملی، شماره تلفن یا ایمیل از قبل وجود دارد."
},
"PROFESSOR_NOT_FOUND": {
"statusCode": 404,
"en": "Professor not found.",
"fa": "استاد یافت نشد."
},
"PROFESSOR_ALREADY_EXISTS": {
"statusCode": 409,
"en": "Professor with this national ID, phone number, or email already exists.",
"fa": "استادی با این کدملی، شماره تلفن یا ایمیل از قبل وجود دارد."
},
"COURSE_NOT_FOUND": {
"statusCode": 404,
"en": "Course not found.",
"fa": "دوره یافت نشد."
},
"COURSE_CAPACITY_FULL": {
"statusCode": 400,
"en": "Course capacity is full.",
"fa": "ظرفیت دوره تکمیل است."
},
"ALREADY_ENROLLED": {
"statusCode": 400,
"en": "User is already enrolled in this course.",
"fa": "کاربر قبلاً در این دوره ثبت‌نام کرده است."
},
"SESSION_NOT_FOUND": {
"statusCode": 404,
"en": "Session not found.",
"fa": "جلسه یافت نشد."
},
"ATTENDANCE_NOT_FOUND": {
"statusCode": 404,
"en": "Attendance record not found.",
"fa": "سابقه حضور و غیاب یافت نشد."
},
"ATTENDANCE_ALREADY_EXISTS": {
"statusCode": 409,
"en": "Attendance record already exists for this student in this session.",
"fa": "سابقه حضور و غیاب برای این دانشجو در این جلسه قبلاً ثبت شده است."
},
"PAYMENT_NOT_FOUND": {
"statusCode": 404,
"en": "Payment record not found.",
"fa": "سابقه پرداخت یافت نشد."
},
"ROLE_NOT_FOUND": {
"statusCode": 404,
"en": "Role not found.",
"fa": "نقش یافت نشد."
},
"ROLE_ALREADY_EXISTS": {
"statusCode": 409,
"en": "Role with this name already exists.",
"fa": "نقشی با این نام از قبل وجود دارد."
},
"SYSTEM_ROLE_PROTECTED": {
"statusCode": 403,
"en": "System roles cannot be modified or deleted.",
"fa": "نقش‌های سیستمی قابل تغییر یا حذف نیستند."
},
"NOTIFICATION_NOT_FOUND": {
"statusCode": 404,
"en": "Notification record not found.",
"fa": "اعلان یافت نشد."
},
"CERTIFICATE_NOT_FOUND": {
"statusCode": 404,
"en": "Certificate not found.",
"fa": "مدرک یافت نشد."
},
"CLASS_NOT_FOUND": {
"statusCode": 404,
"en": "Class not found.",
"fa": "کلاس یافت نشد."
},
"DEFAULT_ROLE_NOT_FOUND": {
"statusCode": 500,
"en": "Default user role was not found. Please seed the database.",
"fa": "نقش پیش‌فرض کاربر یافت نشد. لطفاً دیتابیس را seed کنید."
},
"VALIDATION_FAILED": {
"statusCode": 400,
"en": "Validation failed.",
"fa": "اعتبارسنجی ناموفق بود."
},
"UNAUTHORIZED": {
"statusCode": 401,
"en": "Authentication required. Invalid or missing token.",
"fa": "احراز هویت لازم است. توکن نامعتبر یا یافت نشد."
},
"FORBIDDEN": {
"statusCode": 403,
"en": "Access denied. Insufficient permissions.",
"fa": "دسترسی رد شد. مجوز کافی ندارید."
},
"INVALID_CREDENTIALS": {
"statusCode": 401,
"en": "Invalid username or password.",
"fa": "نام کاربری یا رمز عبور اشتباه است."
},
"TOKEN_EXPIRED": {
"statusCode": 401,
"en": "Token has expired. Please login again.",
"fa": "توکن منقضی شده است. لطفا مجددا وارد شوید."
},
"INVALID_REFRESH_TOKEN": {
"statusCode": 401,
"en": "Invalid or revoked refresh token.",
"fa": "توکن بازنشانی نامعتبر یا باطل شده است."
},
"DUPLICATE_KEY": {
"statusCode": 409,
"en": "Duplicate key error. A unique field already exists.",
"fa": "خطای کلید تکراری. مقداری یکتا از قبل وجود دارد."
},
"NOT_FOUND": {
"statusCode": 404,
"en": "Requested route or resource not found.",
"fa": "مسیر یا منبع درخواستی یافت نشد."
},
"INTERNAL_SERVER_ERROR": {
"statusCode": 500,
"en": "An internal server error occurred.",
"fa": "خطای داخلی سرور رخ داده است."
},
"FILE_REQUIRED": {
"statusCode": 400,
"en": "File is required.",
"fa": "ارسال فایل الزامی است."
}
}
+26
View File
@@ -0,0 +1,26 @@
// /utils/logger.js
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
),
defaultMeta: { service: 'institution-api' },
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, service, stack }) => {
return `[${timestamp}] [${level}]: ${stack || message}`;
})
)
})
]
});
module.exports = logger;
+69
View File
@@ -0,0 +1,69 @@
// /utils/pagination.js
const parsePaginationAndSort = (query, defaultSortBy = 'createdAt', defaultSortOrder = 'desc', maxLimit = 100) => {
const page = Math.max(1, parseInt(query.page, 10) || 1);
let limit = parseInt(query.limit, 10) || 20;
if (limit <= 0) limit = 20;
if (limit > maxLimit) limit = maxLimit;
const skip = (page - 1) * limit;
const sortBy = query.sortBy || defaultSortBy;
const sortOrder = (query.sortOrder || defaultSortOrder).toLowerCase() === 'asc' ? 1 : -1;
const sort = {};
sort[sortBy] = sortOrder;
return {
page,
limit,
skip,
sort
};
};
const buildFilterQuery = (query, searchFields = [], excludedKeys = ['page', 'limit', 'sortBy', 'sortOrder', 'q', 'lang']) => {
const filter = {};
// Build field-based exact or boolean filters
Object.keys(query).forEach((key) => {
if (!excludedKeys.includes(key) && query[key] !== undefined && query[key] !== '') {
const val = query[key];
if (val === 'true') {
filter[key] = true;
} else if (val === 'false') {
filter[key] = false;
} else if (!isNaN(val) && String(Number(val)) === val) {
filter[key] = Number(val);
} else {
filter[key] = val;
}
}
});
// Build regex search for ?q= across defined text fields
if (query.q && searchFields.length > 0) {
const searchRegex = new RegExp(query.q, 'i');
filter.$or = searchFields.map((field) => ({
[field]: searchRegex
}));
}
return filter;
};
const calculateMeta = (totalCount, page, limit) => {
const totalPages = Math.ceil(totalCount / limit) || 0;
return {
totalCount,
totalPages,
currentPage: page,
limit
};
};
module.exports = {
parsePaginationAndSort,
buildFilterQuery,
calculateMeta
};

Some files were not shown because too many files have changed in this diff Show More