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