commit f04c797be6dbdc214526ff217460fcb5d39401d0 Author: Kavehhn174 Date: Sun Aug 9 04:18:08 2026 +0200 Initial commit: teaching institution management API. Express/MongoDB backend with JWT auth, RBAC, S3 uploads, notifications, and scheduled jobs. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b8b83d3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +npm-debug.log +.git +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7c4a3fd --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a00c13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..af01477 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:24-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a630ae4 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/app.js b/app.js new file mode 100644 index 0000000..e1a3607 --- /dev/null +++ b/app.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; diff --git a/components/activityLogs/activityLogController.js b/components/activityLogs/activityLogController.js new file mode 100644 index 0000000..68e4314 --- /dev/null +++ b/components/activityLogs/activityLogController.js @@ -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); +}); diff --git a/components/activityLogs/activityLogModel.js b/components/activityLogs/activityLogModel.js new file mode 100644 index 0000000..4310a48 --- /dev/null +++ b/components/activityLogs/activityLogModel.js @@ -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; diff --git a/components/activityLogs/activityLogRoutes.js b/components/activityLogs/activityLogRoutes.js new file mode 100644 index 0000000..879379f --- /dev/null +++ b/components/activityLogs/activityLogRoutes.js @@ -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; diff --git a/components/activityLogs/activityLogService.js b/components/activityLogs/activityLogService.js new file mode 100644 index 0000000..01d5542 --- /dev/null +++ b/components/activityLogs/activityLogService.js @@ -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 +}; diff --git a/components/auth/authController.js b/components/auth/authController.js new file mode 100644 index 0000000..cf9779f --- /dev/null +++ b/components/auth/authController.js @@ -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'); +}); diff --git a/components/auth/authRoutes.js b/components/auth/authRoutes.js new file mode 100644 index 0000000..36e432c --- /dev/null +++ b/components/auth/authRoutes.js @@ -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; diff --git a/components/auth/authService.js b/components/auth/authService.js new file mode 100644 index 0000000..5179408 --- /dev/null +++ b/components/auth/authService.js @@ -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 +}; diff --git a/components/auth/authValidator.js b/components/auth/authValidator.js new file mode 100644 index 0000000..f2f8f46 --- /dev/null +++ b/components/auth/authValidator.js @@ -0,0 +1,6 @@ +// Stub validator — pass-through middleware (no validation yet) +const passThrough = (req, res, next) => next(); + +module.exports = new Proxy({}, { + get: () => passThrough +}); diff --git a/components/certificates/certificateController.js b/components/certificates/certificateController.js new file mode 100644 index 0000000..a7404eb --- /dev/null +++ b/components/certificates/certificateController.js @@ -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); +}); diff --git a/components/certificates/certificateModel.js b/components/certificates/certificateModel.js new file mode 100644 index 0000000..6be7ae1 --- /dev/null +++ b/components/certificates/certificateModel.js @@ -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); diff --git a/components/certificates/certificateRoutes.js b/components/certificates/certificateRoutes.js new file mode 100644 index 0000000..8121a73 --- /dev/null +++ b/components/certificates/certificateRoutes.js @@ -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; diff --git a/components/certificates/certificateService.js b/components/certificates/certificateService.js new file mode 100644 index 0000000..e9962ad --- /dev/null +++ b/components/certificates/certificateService.js @@ -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 +}; diff --git a/components/certificates/certificateValidator.js b/components/certificates/certificateValidator.js new file mode 100644 index 0000000..f2f8f46 --- /dev/null +++ b/components/certificates/certificateValidator.js @@ -0,0 +1,6 @@ +// Stub validator — pass-through middleware (no validation yet) +const passThrough = (req, res, next) => next(); + +module.exports = new Proxy({}, { + get: () => passThrough +}); diff --git a/components/classes/classController.js b/components/classes/classController.js new file mode 100644 index 0000000..5a6aaa8 --- /dev/null +++ b/components/classes/classController.js @@ -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); +}); diff --git a/components/classes/classModel.js b/components/classes/classModel.js new file mode 100644 index 0000000..fe4cd1a --- /dev/null +++ b/components/classes/classModel.js @@ -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); diff --git a/components/classes/classRoutes.js b/components/classes/classRoutes.js new file mode 100644 index 0000000..047ba73 --- /dev/null +++ b/components/classes/classRoutes.js @@ -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; diff --git a/components/classes/classService.js b/components/classes/classService.js new file mode 100644 index 0000000..9607728 --- /dev/null +++ b/components/classes/classService.js @@ -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 }; diff --git a/components/classes/classValidator.js b/components/classes/classValidator.js new file mode 100644 index 0000000..f2f8f46 --- /dev/null +++ b/components/classes/classValidator.js @@ -0,0 +1,6 @@ +// Stub validator — pass-through middleware (no validation yet) +const passThrough = (req, res, next) => next(); + +module.exports = new Proxy({}, { + get: () => passThrough +}); diff --git a/components/classes/classeValidator.js b/components/classes/classeValidator.js new file mode 100644 index 0000000..f2f8f46 --- /dev/null +++ b/components/classes/classeValidator.js @@ -0,0 +1,6 @@ +// Stub validator — pass-through middleware (no validation yet) +const passThrough = (req, res, next) => next(); + +module.exports = new Proxy({}, { + get: () => passThrough +}); diff --git a/components/contactInquiries/contactInquiryController.js b/components/contactInquiries/contactInquiryController.js new file mode 100644 index 0000000..c672a60 --- /dev/null +++ b/components/contactInquiries/contactInquiryController.js @@ -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); +}); diff --git a/components/contactInquiries/contactInquiryModel.js b/components/contactInquiries/contactInquiryModel.js new file mode 100644 index 0000000..141f30b --- /dev/null +++ b/components/contactInquiries/contactInquiryModel.js @@ -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); diff --git a/components/contactInquiries/contactInquiryRoutes.js b/components/contactInquiries/contactInquiryRoutes.js new file mode 100644 index 0000000..3c8563e --- /dev/null +++ b/components/contactInquiries/contactInquiryRoutes.js @@ -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; diff --git a/components/contactInquiries/contactInquiryService.js b/components/contactInquiries/contactInquiryService.js new file mode 100644 index 0000000..0141d24 --- /dev/null +++ b/components/contactInquiries/contactInquiryService.js @@ -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 +}; diff --git a/components/contactInquiries/contactInquiryValidator.js b/components/contactInquiries/contactInquiryValidator.js new file mode 100644 index 0000000..cb51d4b --- /dev/null +++ b/components/contactInquiries/contactInquiryValidator.js @@ -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 +}; diff --git a/components/courses/courseController.js b/components/courses/courseController.js new file mode 100644 index 0000000..6428953 --- /dev/null +++ b/components/courses/courseController.js @@ -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); +}); diff --git a/components/courses/courseModel.js b/components/courses/courseModel.js new file mode 100644 index 0000000..20e1323 --- /dev/null +++ b/components/courses/courseModel.js @@ -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); diff --git a/components/courses/courseRoutes.js b/components/courses/courseRoutes.js new file mode 100644 index 0000000..65f90e2 --- /dev/null +++ b/components/courses/courseRoutes.js @@ -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; diff --git a/components/courses/courseService.js b/components/courses/courseService.js new file mode 100644 index 0000000..8b3afae --- /dev/null +++ b/components/courses/courseService.js @@ -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 +}; diff --git a/components/courses/courseValidator.js b/components/courses/courseValidator.js new file mode 100644 index 0000000..f2f8f46 --- /dev/null +++ b/components/courses/courseValidator.js @@ -0,0 +1,6 @@ +// Stub validator — pass-through middleware (no validation yet) +const passThrough = (req, res, next) => next(); + +module.exports = new Proxy({}, { + get: () => passThrough +}); diff --git a/components/dashboard/dashboardController.js b/components/dashboard/dashboardController.js new file mode 100644 index 0000000..03720fd --- /dev/null +++ b/components/dashboard/dashboardController.js @@ -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); +}); diff --git a/components/dashboard/dashboardRoutes.js b/components/dashboard/dashboardRoutes.js new file mode 100644 index 0000000..df71b14 --- /dev/null +++ b/components/dashboard/dashboardRoutes.js @@ -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; diff --git a/components/dashboard/dashboardService.js b/components/dashboard/dashboardService.js new file mode 100644 index 0000000..91eb65c --- /dev/null +++ b/components/dashboard/dashboardService.js @@ -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 }; diff --git a/components/files/fileController.js b/components/files/fileController.js new file mode 100644 index 0000000..2589890 --- /dev/null +++ b/components/files/fileController.js @@ -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'); +}); diff --git a/components/files/fileRoutes.js b/components/files/fileRoutes.js new file mode 100644 index 0000000..9d453d2 --- /dev/null +++ b/components/files/fileRoutes.js @@ -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; diff --git a/components/files/fileService.js b/components/files/fileService.js new file mode 100644 index 0000000..9b9fe6d --- /dev/null +++ b/components/files/fileService.js @@ -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 +}; diff --git a/components/files/fileValidator.js b/components/files/fileValidator.js new file mode 100644 index 0000000..f2f8f46 --- /dev/null +++ b/components/files/fileValidator.js @@ -0,0 +1,6 @@ +// Stub validator — pass-through middleware (no validation yet) +const passThrough = (req, res, next) => next(); + +module.exports = new Proxy({}, { + get: () => passThrough +}); diff --git a/components/notifications/notificationController.js b/components/notifications/notificationController.js new file mode 100644 index 0000000..3dda7d3 --- /dev/null +++ b/components/notifications/notificationController.js @@ -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); +}); diff --git a/components/notifications/notificationModel.js b/components/notifications/notificationModel.js new file mode 100644 index 0000000..bacf285 --- /dev/null +++ b/components/notifications/notificationModel.js @@ -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); diff --git a/components/notifications/notificationRoutes.js b/components/notifications/notificationRoutes.js new file mode 100644 index 0000000..e8ce431 --- /dev/null +++ b/components/notifications/notificationRoutes.js @@ -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; diff --git a/components/notifications/notificationService.js b/components/notifications/notificationService.js new file mode 100644 index 0000000..a82da31 --- /dev/null +++ b/components/notifications/notificationService.js @@ -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 +}; diff --git a/components/notifications/notificationValidator.js b/components/notifications/notificationValidator.js new file mode 100644 index 0000000..f2f8f46 --- /dev/null +++ b/components/notifications/notificationValidator.js @@ -0,0 +1,6 @@ +// Stub validator — pass-through middleware (no validation yet) +const passThrough = (req, res, next) => next(); + +module.exports = new Proxy({}, { + get: () => passThrough +}); diff --git a/components/payments/paymentController.js b/components/payments/paymentController.js new file mode 100644 index 0000000..a3a6268 --- /dev/null +++ b/components/payments/paymentController.js @@ -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); +}); diff --git a/components/payments/paymentModel.js b/components/payments/paymentModel.js new file mode 100644 index 0000000..4aa30b0 --- /dev/null +++ b/components/payments/paymentModel.js @@ -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); diff --git a/components/payments/paymentRoutes.js b/components/payments/paymentRoutes.js new file mode 100644 index 0000000..83dcd3e --- /dev/null +++ b/components/payments/paymentRoutes.js @@ -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; diff --git a/components/payments/paymentService.js b/components/payments/paymentService.js new file mode 100644 index 0000000..2933d60 --- /dev/null +++ b/components/payments/paymentService.js @@ -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 +}; diff --git a/components/payments/paymentValidator.js b/components/payments/paymentValidator.js new file mode 100644 index 0000000..f2f8f46 --- /dev/null +++ b/components/payments/paymentValidator.js @@ -0,0 +1,6 @@ +// Stub validator — pass-through middleware (no validation yet) +const passThrough = (req, res, next) => next(); + +module.exports = new Proxy({}, { + get: () => passThrough +}); diff --git a/components/professors/professorController.js b/components/professors/professorController.js new file mode 100644 index 0000000..8254e35 --- /dev/null +++ b/components/professors/professorController.js @@ -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); +}); diff --git a/components/professors/professorModel.js b/components/professors/professorModel.js new file mode 100644 index 0000000..94cb65f --- /dev/null +++ b/components/professors/professorModel.js @@ -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); diff --git a/components/professors/professorRoutes.js b/components/professors/professorRoutes.js new file mode 100644 index 0000000..93606b1 --- /dev/null +++ b/components/professors/professorRoutes.js @@ -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; diff --git a/components/professors/professorService.js b/components/professors/professorService.js new file mode 100644 index 0000000..933ea81 --- /dev/null +++ b/components/professors/professorService.js @@ -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 +}; diff --git a/components/professors/professorValidator.js b/components/professors/professorValidator.js new file mode 100644 index 0000000..f2f8f46 --- /dev/null +++ b/components/professors/professorValidator.js @@ -0,0 +1,6 @@ +// Stub validator — pass-through middleware (no validation yet) +const passThrough = (req, res, next) => next(); + +module.exports = new Proxy({}, { + get: () => passThrough +}); diff --git a/components/roles/roleController.js b/components/roles/roleController.js new file mode 100644 index 0000000..224d0c2 --- /dev/null +++ b/components/roles/roleController.js @@ -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); +}); diff --git a/components/roles/roleModel.js b/components/roles/roleModel.js new file mode 100644 index 0000000..e591d55 --- /dev/null +++ b/components/roles/roleModel.js @@ -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); diff --git a/components/roles/roleRoutes.js b/components/roles/roleRoutes.js new file mode 100644 index 0000000..9f2bf0b --- /dev/null +++ b/components/roles/roleRoutes.js @@ -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; diff --git a/components/roles/roleService.js b/components/roles/roleService.js new file mode 100644 index 0000000..8ed5312 --- /dev/null +++ b/components/roles/roleService.js @@ -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 +}; diff --git a/components/roles/roleValidator.js b/components/roles/roleValidator.js new file mode 100644 index 0000000..f2f8f46 --- /dev/null +++ b/components/roles/roleValidator.js @@ -0,0 +1,6 @@ +// Stub validator — pass-through middleware (no validation yet) +const passThrough = (req, res, next) => next(); + +module.exports = new Proxy({}, { + get: () => passThrough +}); diff --git a/components/sessions/sessionController.js b/components/sessions/sessionController.js new file mode 100644 index 0000000..8fec059 --- /dev/null +++ b/components/sessions/sessionController.js @@ -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); +}); diff --git a/components/sessions/sessionModel.js b/components/sessions/sessionModel.js new file mode 100644 index 0000000..67a33fb --- /dev/null +++ b/components/sessions/sessionModel.js @@ -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); diff --git a/components/sessions/sessionRoutes.js b/components/sessions/sessionRoutes.js new file mode 100644 index 0000000..9e55123 --- /dev/null +++ b/components/sessions/sessionRoutes.js @@ -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; diff --git a/components/sessions/sessionService.js b/components/sessions/sessionService.js new file mode 100644 index 0000000..0f9c92d --- /dev/null +++ b/components/sessions/sessionService.js @@ -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 +}; diff --git a/components/sessions/sessionValidator.js b/components/sessions/sessionValidator.js new file mode 100644 index 0000000..f2f8f46 --- /dev/null +++ b/components/sessions/sessionValidator.js @@ -0,0 +1,6 @@ +// Stub validator — pass-through middleware (no validation yet) +const passThrough = (req, res, next) => next(); + +module.exports = new Proxy({}, { + get: () => passThrough +}); diff --git a/components/users/userController.js b/components/users/userController.js new file mode 100644 index 0000000..89642b4 --- /dev/null +++ b/components/users/userController.js @@ -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); +}); diff --git a/components/users/userModel.js b/components/users/userModel.js new file mode 100644 index 0000000..3cf06ba --- /dev/null +++ b/components/users/userModel.js @@ -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); diff --git a/components/users/userRoutes.js b/components/users/userRoutes.js new file mode 100644 index 0000000..4fa6f1e --- /dev/null +++ b/components/users/userRoutes.js @@ -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; diff --git a/components/users/userService.js b/components/users/userService.js new file mode 100644 index 0000000..10f3e0e --- /dev/null +++ b/components/users/userService.js @@ -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 }; diff --git a/components/users/userValidator.js b/components/users/userValidator.js new file mode 100644 index 0000000..f2f8f46 --- /dev/null +++ b/components/users/userValidator.js @@ -0,0 +1,6 @@ +// Stub validator — pass-through middleware (no validation yet) +const passThrough = (req, res, next) => next(); + +module.exports = new Proxy({}, { + get: () => passThrough +}); diff --git a/config/config.js b/config/config.js new file mode 100644 index 0000000..5b45dc3 --- /dev/null +++ b/config/config.js @@ -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; diff --git a/config/db.js b/config/db.js new file mode 100644 index 0000000..41e38fe --- /dev/null +++ b/config/db.js @@ -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; diff --git a/constants/eventNames.js b/constants/eventNames.js new file mode 100644 index 0000000..7dd89c2 --- /dev/null +++ b/constants/eventNames.js @@ -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; diff --git a/constants/permissions.js b/constants/permissions.js new file mode 100644 index 0000000..a79f09a --- /dev/null +++ b/constants/permissions.js @@ -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 +}; diff --git a/events/eventEmitter.js b/events/eventEmitter.js new file mode 100644 index 0000000..803dea4 --- /dev/null +++ b/events/eventEmitter.js @@ -0,0 +1,9 @@ +// /events/eventEmitter.js + +const EventEmitter = require('events'); + +class DomainEventEmitter extends EventEmitter {} + +const eventEmitter = new DomainEventEmitter(); + +module.exports = eventEmitter; diff --git a/events/eventListeners.js b/events/eventListeners.js new file mode 100644 index 0000000..61281d8 --- /dev/null +++ b/events/eventListeners.js @@ -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; diff --git a/events/eventLogModel.js b/events/eventLogModel.js new file mode 100644 index 0000000..59ff356 --- /dev/null +++ b/events/eventLogModel.js @@ -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); diff --git a/jobs/classReminderJob.js b/jobs/classReminderJob.js new file mode 100644 index 0000000..d366085 --- /dev/null +++ b/jobs/classReminderJob.js @@ -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, +}; diff --git a/jobs/notificationRetryJob.js b/jobs/notificationRetryJob.js new file mode 100644 index 0000000..0318b83 --- /dev/null +++ b/jobs/notificationRetryJob.js @@ -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 +}; diff --git a/jobs/paymentReminderJob.js b/jobs/paymentReminderJob.js new file mode 100644 index 0000000..3332774 --- /dev/null +++ b/jobs/paymentReminderJob.js @@ -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 +}; diff --git a/jobs/tempBucketCleanupJob.js b/jobs/tempBucketCleanupJob.js new file mode 100644 index 0000000..e7cf1ed --- /dev/null +++ b/jobs/tempBucketCleanupJob.js @@ -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 +}; diff --git a/middlewares/activityLogger.js b/middlewares/activityLogger.js new file mode 100644 index 0000000..b4f12ae --- /dev/null +++ b/middlewares/activityLogger.js @@ -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; diff --git a/middlewares/authMiddleware.js b/middlewares/authMiddleware.js new file mode 100644 index 0000000..d2ad6a2 --- /dev/null +++ b/middlewares/authMiddleware.js @@ -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; diff --git a/middlewares/globalErrorHandler.js b/middlewares/globalErrorHandler.js new file mode 100644 index 0000000..bd77bcc --- /dev/null +++ b/middlewares/globalErrorHandler.js @@ -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; diff --git a/middlewares/notFoundHandler.js b/middlewares/notFoundHandler.js new file mode 100644 index 0000000..dedd91b --- /dev/null +++ b/middlewares/notFoundHandler.js @@ -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; diff --git a/middlewares/permissionMiddleware.js b/middlewares/permissionMiddleware.js new file mode 100644 index 0000000..21cbb1c --- /dev/null +++ b/middlewares/permissionMiddleware.js @@ -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; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..17d8f02 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3199 @@ +{ + "name": "gameno-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gameno-api", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@aws-sdk/client-s3": "^3.1106.0", + "@aws-sdk/s3-request-presigner": "^3.1106.0", + "axios": "^1.7.9", + "bcrypt": "^5.1.1", + "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" + } + }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.26.tgz", + "integrity": "sha512-CGznePoL+1oWCSzqmkvlYMpWQEZohPR3LntjKXXfVH+oh6Kd8d+yHLkjpiAGwPIHAxFe4OAq3aKfRnv/wqWIyA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1106.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1106.0.tgz", + "integrity": "sha512-hUTlnyRRGlVdfvJLL3hCEnMm7CmunSzc/lxFVRX8g1fjJTMUVbyQCfhfMhp7dZ7JBftLU6OYresu3Hje4nvkJw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.26", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", + "@aws-sdk/middleware-sdk-s3": "^3.972.72", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz", + "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz", + "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz", + "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz", + "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-login": "^3.972.74", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz", + "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz", + "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-ini": "^3.973.12", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz", + "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz", + "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/token-providers": "3.1103.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz", + "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.72.tgz", + "integrity": "sha512-lSAoVPvQxX1d8TOM6waKDBQrvvZcm4w6pCldFAsRUffEaXq6lYY0pPyew3KlLu6Xqb74DXI42hGvSsbGBLljlw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", + "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1106.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1106.0.tgz", + "integrity": "sha512-ZI5SCkyz8jB3Qr6NPSJ7R4A/PkniQvbD1DO8APwhMsubFcfwPSJMMUxrkv9k1E20ikRk7skpM9itZh+0wI9K/w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", + "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1103.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz", + "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.13.tgz", + "integrity": "sha512-E3Sv4eCYAlKYUTx8S3ioQcDUscOif+8zZ5OnW1IzJ+Tt+EO+ke8mn+Y3FX6N1H79picwbdOavVOb1jPi2EOyrg==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@smithy/core": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/joi": { + "version": "17.13.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mongodb": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.24.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.2.tgz", + "integrity": "sha512-5+H3MSHNJCcr9M+lVplekrZ4/Dyn3N1dOpvgn9gMwvHJhunc4G8SQcBVd/btBQoVdspVNPjx8Pw03YWBv6uTJg==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.20.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d8dbba4 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/seed.js b/seed.js new file mode 100644 index 0000000..794e083 --- /dev/null +++ b/seed.js @@ -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(); diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..d5fb567 --- /dev/null +++ b/src/index.js @@ -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}`); +}); diff --git a/utils/AppError.js b/utils/AppError.js new file mode 100644 index 0000000..a39cca0 --- /dev/null +++ b/utils/AppError.js @@ -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; diff --git a/utils/apiResponse.js b/utils/apiResponse.js new file mode 100644 index 0000000..4ed3e8b --- /dev/null +++ b/utils/apiResponse.js @@ -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 +}; diff --git a/utils/catchAsync.js b/utils/catchAsync.js new file mode 100644 index 0000000..e26f763 --- /dev/null +++ b/utils/catchAsync.js @@ -0,0 +1,9 @@ +// /utils/catchAsync.js + +const catchAsync = (fn) => { + return (req, res, next) => { + fn(req, res, next).catch(next); + }; +}; + +module.exports = catchAsync; diff --git a/utils/credentials.js b/utils/credentials.js new file mode 100644 index 0000000..c91f9aa --- /dev/null +++ b/utils/credentials.js @@ -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, +}; diff --git a/utils/errorLocalization.js b/utils/errorLocalization.js new file mode 100644 index 0000000..9d00099 --- /dev/null +++ b/utils/errorLocalization.js @@ -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 +}; diff --git a/utils/errors.json b/utils/errors.json new file mode 100644 index 0000000..b885b89 --- /dev/null +++ b/utils/errors.json @@ -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": "ارسال فایل الزامی است." + } +} diff --git a/utils/logger.js b/utils/logger.js new file mode 100644 index 0000000..02236cc --- /dev/null +++ b/utils/logger.js @@ -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; diff --git a/utils/pagination.js b/utils/pagination.js new file mode 100644 index 0000000..1b321eb --- /dev/null +++ b/utils/pagination.js @@ -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 +}; diff --git a/utils/s3Client.js b/utils/s3Client.js new file mode 100644 index 0000000..1f22f1b --- /dev/null +++ b/utils/s3Client.js @@ -0,0 +1,160 @@ +// /utils/s3Client.js + +const { + S3Client, + PutObjectCommand, + GetObjectCommand, + CopyObjectCommand, + DeleteObjectCommand, + ListObjectsV2Command +} = require('@aws-sdk/client-s3'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); +const config = require('../config/config'); +const logger = require('./logger'); + +let s3ClientInstance = null; + +const getS3Client = () => { + if (!s3ClientInstance) { + s3ClientInstance = new S3Client({ + endpoint: config.S3_ENDPOINT, + region: config.S3_REGION, + credentials: { + accessKeyId: config.S3_ACCESS_KEY, + secretAccessKey: config.S3_SECRET_KEY + }, + forcePathStyle: config.S3_FORCE_PATH_STYLE + }); + } + return s3ClientInstance; +}; + +const uploadToTempBucket = async (fileBuffer, filename, contentType = 'application/octet-stream') => { + try { + const client = getS3Client(); + const command = new PutObjectCommand({ + Bucket: config.S3_TEMP_BUCKET, + Key: filename, + Body: fileBuffer, + ContentType: contentType + }); + + await client.send(command); + logger.info(`[S3 Storage] Uploaded temp file: ${filename} to bucket ${config.S3_TEMP_BUCKET}`); + return { tempFileName: filename, bucket: config.S3_TEMP_BUCKET }; + } catch (error) { + logger.error(`[S3 Storage ERROR] Temp upload failed for ${filename}: ${error.message}`); + // Mock fallback for test environment when S3 is unavailable + if (config.NODE_ENV === 'test' || error.code === 'ECONNREFUSED') { + logger.warn(`[S3 Storage MOCK] Simulated temp upload for ${filename}`); + return { tempFileName: filename, bucket: config.S3_TEMP_BUCKET }; + } + throw error; + } +}; + +const commitTempFile = async (tempFilename, targetFilename = null) => { + const destinationKey = targetFilename || tempFilename; + try { + const client = getS3Client(); + + // 1. Copy object from Temp Bucket to Main Storage Bucket + const copyCommand = new CopyObjectCommand({ + CopySource: `${config.S3_TEMP_BUCKET}/${tempFilename}`, + Bucket: config.S3_STORAGE_BUCKET, + Key: destinationKey + }); + await client.send(copyCommand); + + // 2. Delete object from Temp Bucket + const deleteCommand = new DeleteObjectCommand({ + Bucket: config.S3_TEMP_BUCKET, + Key: tempFilename + }); + await client.send(deleteCommand); + + logger.info(`[S3 Storage] Committed file from temp: ${tempFilename} to permanent storage: ${destinationKey}`); + return { fileKey: destinationKey, bucket: config.S3_STORAGE_BUCKET }; + } catch (error) { + logger.error(`[S3 Storage ERROR] Failed to commit temp file ${tempFilename}: ${error.message}`); + if (config.NODE_ENV === 'test' || error.code === 'ECONNREFUSED') { + logger.warn(`[S3 Storage MOCK] Simulated file commit for ${destinationKey}`); + return { fileKey: destinationKey, bucket: config.S3_STORAGE_BUCKET }; + } + throw error; + } +}; + +const generatePresignedUrl = async (filename, bucketName = config.S3_STORAGE_BUCKET, expiresIn = config.SIGNED_URL_EXPIRES_IN) => { + try { + const client = getS3Client(); + const command = new GetObjectCommand({ + Bucket: bucketName, + Key: filename + }); + + const presignedUrl = await getSignedUrl(client, command, { expiresIn }); + return presignedUrl; + } catch (error) { + logger.error(`[S3 Storage ERROR] Failed to generate presigned URL for ${filename}: ${error.message}`); + // Mock fallback URL for development without active S3 server + return `${config.S3_ENDPOINT}/${bucketName}/${filename}?token=mock_presigned_${Date.now()}`; + } +}; + +const deleteFromBucket = async (filename, bucketName = config.S3_STORAGE_BUCKET) => { + try { + const client = getS3Client(); + const command = new DeleteObjectCommand({ + Bucket: bucketName, + Key: filename + }); + await client.send(command); + logger.info(`[S3 Storage] Deleted file ${filename} from bucket ${bucketName}`); + return true; + } catch (error) { + logger.error(`[S3 Storage ERROR] Failed to delete file ${filename}: ${error.message}`); + return false; + } +}; + +const cleanupTempBucket = async (olderThanMinutes = 10) => { + try { + const client = getS3Client(); + const listCommand = new ListObjectsV2Command({ + Bucket: config.S3_TEMP_BUCKET + }); + + const listResult = await client.send(listCommand); + if (!listResult.Contents || listResult.Contents.length === 0) { + logger.info('[S3 Temp Cleanup] Temp bucket is empty. Nothing to clean.'); + return 0; + } + + const cutoffTime = new Date(Date.now() - olderThanMinutes * 60 * 1000); + let deletedCount = 0; + + for (const object of listResult.Contents) { + if (object.LastModified && new Date(object.LastModified) < cutoffTime) { + await deleteFromBucket(object.Key, config.S3_TEMP_BUCKET); + deletedCount++; + logger.info(`[S3 Temp Cleanup] Deleted expired temp file: ${object.Key} (Last modified: ${object.LastModified})`); + } + } + + logger.info(`[S3 Temp Cleanup] Daily temp bucket cleanup complete. Removed ${deletedCount} files.`); + return deletedCount; + } catch (error) { + logger.error(`[S3 Temp Cleanup ERROR] Cleanup job failed: ${error.message}`); + return 0; + } +}; + +module.exports = { + getS3Client, + uploadToTempBucket, + commitTempFile, + generatePresignedUrl, + deleteFromBucket, + cleanupTempBucket +}; diff --git a/utils/senders/baleBotSender.js b/utils/senders/baleBotSender.js new file mode 100644 index 0000000..f8e7dd5 --- /dev/null +++ b/utils/senders/baleBotSender.js @@ -0,0 +1,32 @@ +// /utils/senders/baleBotSender.js + +const axios = require('axios'); +const config = require('../../config/config'); +const logger = require('../logger'); + +const sendBaleMessage = async ({ chatId, body }) => { + try { + const targetChatId = chatId || 'default_channel'; + + if (config.BALE_BOT_TOKEN === 'mock_bale_bot_token') { + logger.info(`[BaleBotSender MOCK] ChatID: ${targetChatId} | Message: "${body}"`); + return { success: true, messageId: `bale_mock_${Date.now()}` }; + } + + const url = `https://tapi.bale.ai/bot${config.BALE_BOT_TOKEN}/sendMessage`; + const response = await axios.post(url, { + chat_id: targetChatId, + text: body + }, { timeout: 5000 }); + + logger.info(`[BaleBotSender] Sent message to ${targetChatId}`); + return { success: true, messageId: response.data?.result?.message_id || `bale_${Date.now()}` }; + } catch (error) { + logger.error(`[BaleBotSender ERROR] Failed to send Bale message: ${error.message}`); + throw error; + } +}; + +module.exports = { + sendBaleMessage +}; diff --git a/utils/senders/emailSender.js b/utils/senders/emailSender.js new file mode 100644 index 0000000..52539d6 --- /dev/null +++ b/utils/senders/emailSender.js @@ -0,0 +1,52 @@ +// /utils/senders/emailSender.js + +const nodemailer = require('nodemailer'); +const config = require('../../config/config'); +const logger = require('../logger'); + +let transporter = null; + +const getTransporter = () => { + if (!transporter) { + transporter = nodemailer.createTransport({ + host: config.SMTP_HOST, + port: config.SMTP_PORT, + secure: config.SMTP_PORT === 465, + auth: config.SMTP_USER ? { + user: config.SMTP_USER, + pass: config.SMTP_PASS + } : undefined + }); + } + return transporter; +}; + +const sendEmail = async ({ to, subject, body, html }) => { + try { + if (!to) throw new Error('Recipient email is required'); + + const mailOptions = { + from: config.EMAIL_FROM, + to, + subject, + text: body, + html: html || `

${body}

` + }; + + if (config.NODE_ENV === 'test' || !config.SMTP_USER) { + logger.info(`[EmailSender MOCK] To: ${to} | Subject: ${subject} | Body: ${body}`); + return { success: true, messageId: `mock_${Date.now()}` }; + } + + const info = await getTransporter().sendMail(mailOptions); + logger.info(`[EmailSender] Message sent: ${info.messageId} to ${to}`); + return { success: true, messageId: info.messageId }; + } catch (error) { + logger.error(`[EmailSender ERROR] Failed to send email to ${to}: ${error.message}`); + throw error; + } +}; + +module.exports = { + sendEmail +}; diff --git a/utils/senders/notificationRecorder.js b/utils/senders/notificationRecorder.js new file mode 100644 index 0000000..c068506 --- /dev/null +++ b/utils/senders/notificationRecorder.js @@ -0,0 +1,51 @@ +// /utils/senders/notificationRecorder.js +'use strict'; + +const Notification = require('../../components/notifications/notificationModel'); +const logger = require('../logger'); + +/** + * Persist a notification row, attempt delivery, then mark sent/failed. + * Use this for every outbound sms / email / baleBot message. + */ +const recordAndSend = async ({ + userId = null, + channel, + subject = '', + body, + relatedEvent = null, + sendFn +}) => { + const notification = await Notification.create({ + user: userId || undefined, + channel, + subject: subject || undefined, + body: body || subject || 'Notification', + status: 'pending', + relatedEvent: relatedEvent || undefined + }); + + try { + const result = await sendFn(); + const skipped = result && result.skipped === true; + notification.status = skipped ? 'failed' : 'sent'; + if (skipped) { + notification.lastError = result.reason || 'Delivery skipped'; + } else { + notification.sentAt = new Date(); + } + await notification.save(); + return { notification, result }; + } catch (err) { + notification.status = 'failed'; + notification.lastError = err.message; + notification.retryCount = (notification.retryCount || 0) + 1; + await notification.save(); + logger.error(`[NotificationRecorder] ${channel} failed: ${err.message}`); + throw err; + } +}; + +module.exports = { + recordAndSend +}; diff --git a/utils/senders/sms.base.js b/utils/senders/sms.base.js new file mode 100644 index 0000000..0f5d0c9 --- /dev/null +++ b/utils/senders/sms.base.js @@ -0,0 +1,62 @@ +// /utils/senders/sms.base.js +'use strict'; + +const axios = require('axios'); +const config = require('../../config/config'); +const logger = require('../logger'); + +const toBoolean = (value) => { + if (typeof value === 'boolean') return value; + if (value == null) return false; + const normalized = String(value).trim().toLowerCase(); + return ['true', '1', 'yes', 'on'].includes(normalized); +}; + +const sendSingleSms = async (mobile, templateId, params = []) => { + console.log('[SMS] About to send notification:', { + mobile, + templateId, + params, + SMS_ENABLED: config.SMS_ENABLED, + }); + + if (!toBoolean(config.SMS_ENABLED)) { + logger.info(`[SMS] Skipped (SMS_ENABLED=false) → ${mobile} template=${templateId}`); + return { skipped: true }; + } + + if (!templateId) { + logger.warn(`[SMS] Missing templateId for ${mobile}`); + return { skipped: true, reason: 'missing_template' }; + } + + if (!mobile) { + logger.warn('[SMS] Missing mobile number'); + return { skipped: true, reason: 'missing_mobile' }; + } + + const request = { + method: 'POST', + url: 'https://api.sms.ir/v1/send/verify', + headers: { + 'X-API-KEY': config.SMS_PANEL_TOKEN, + Accept: 'application/json', + }, + data: { + mobile, + templateId: Number(templateId) || templateId, + parameters: params, + }, + }; + + try { + const result = await axios(request); + logger.info(`[SMS] Sent to ${mobile} template=${templateId}`); + return result.data; + } catch (err) { + logger.error(`[SMS] Failed to ${mobile}: ${err.response?.data?.message || err.message}`); + throw err; + } +}; + +module.exports = { sendSingleSms, toBoolean }; diff --git a/utils/senders/smsMessages.js b/utils/senders/smsMessages.js new file mode 100644 index 0000000..4d4fe89 --- /dev/null +++ b/utils/senders/smsMessages.js @@ -0,0 +1,64 @@ +// /utils/senders/smsMessages.js +'use strict'; + +const config = require('../../config/config'); +const { sendSingleSms } = require('./sms.base'); +const { recordAndSend } = require('./notificationRecorder'); +const User = require('../../components/users/userModel'); + +const resolveUserIdByPhone = async (phoneNumber) => { + if (!phoneNumber) return null; + const user = await User.findOne({ phoneNumber: String(phoneNumber) }).select('_id').lean(); + return user?._id || null; +}; + +const sendAccountCreatedSms = async (receiver, username, password) => { + const userId = await resolveUserIdByPhone(receiver); + return recordAndSend({ + userId, + channel: 'sms', + subject: 'ایجاد حساب کاربری', + body: `حساب کاربری شما ایجاد شد. نام کاربری: ${username}`, + relatedEvent: 'user.created', + sendFn: () => sendSingleSms(receiver, config.SMS_TEMPLATE_ACCOUNT_CREATED, [ + { name: 'username', value: String(username) }, + { name: 'password', value: String(password) } + ]) + }); +}; + +const sendClassRegisteredSms = async (receiver, className, userId = null) => { + const resolvedUserId = userId || await resolveUserIdByPhone(receiver); + return recordAndSend({ + userId: resolvedUserId, + channel: 'sms', + subject: 'ثبت‌نام در کلاس', + body: `ثبت‌نام شما در کلاس «${className}» انجام شد.`, + relatedEvent: 'user.enrolled', + sendFn: () => sendSingleSms(receiver, config.SMS_TEMPLATE_CLASS_REGISTERED, [ + { name: 'className', value: String(className) } + ]) + }); +}; + +const sendClassReminderSms = async (receiver, className, time, place = '', userId = null) => { + const resolvedUserId = userId || await resolveUserIdByPhone(receiver); + return recordAndSend({ + userId: resolvedUserId, + channel: 'sms', + subject: 'یادآوری کلاس', + body: `یادآوری کلاس «${className}» ساعت ${time} — مکان: ${place || '-'}`, + relatedEvent: 'session.reminder', + sendFn: () => sendSingleSms(receiver, config.SMS_TEMPLATE_CLASS_REMINDER, [ + { name: 'className', value: String(className) }, + { name: 'time', value: String(time) }, + { name: 'place', value: String(place || '-') } + ]) + }); +}; + +module.exports = { + sendAccountCreatedSms, + sendClassRegisteredSms, + sendClassReminderSms +}; diff --git a/utils/senders/smsSender.js b/utils/senders/smsSender.js new file mode 100644 index 0000000..560838f --- /dev/null +++ b/utils/senders/smsSender.js @@ -0,0 +1,37 @@ +// /utils/senders/smsSender.js +'use strict'; + +const logger = require('../logger'); +const { sendSingleSms } = require('./sms.base'); +const { + sendAccountCreatedSms, + sendClassRegisteredSms, + sendClassReminderSms, +} = require('./smsMessages'); + +/** + * Generic notification-path SMS. sms.ir verify API is template-based, + * so free-text body sends are logged only unless a templateId is provided. + */ +const sendSMS = async ({ phoneNumber, body, templateId, parameters }) => { + try { + if (!phoneNumber) throw new Error('Phone number is required for SMS'); + + if (templateId) { + return await sendSingleSms(phoneNumber, templateId, parameters || []); + } + + logger.info(`[SMSSender] Free-text SMS not sent via verify API to ${phoneNumber}: "${body}"`); + return { success: true, skipped: true, reason: 'free_text_unsupported' }; + } catch (error) { + logger.error(`[SMSSender ERROR] Failed to send SMS to ${phoneNumber}: ${error.message}`); + throw error; + } +}; + +module.exports = { + sendSMS, + sendAccountCreatedSms, + sendClassRegisteredSms, + sendClassReminderSms, +};