From 70838a4bc152f0c1908683a84ce16c3efe5f403d Mon Sep 17 00:00:00 2001 From: Ken Date: Mon, 19 Jan 2026 09:33:35 +0700 Subject: [PATCH] update --- LOGIN_GUIDE.md | 344 +++ README.md | 2 - app.js | 244 ++ config/bullmq.js | 256 ++ config/config.json | 40 + config/database.js | 98 + config/redis.js | 169 + controllers/academicYearController.js | 270 ++ controllers/attendanceController.js | 248 ++ controllers/authController.js | 351 +++ controllers/chapterController.js | 335 ++ controllers/classController.js | 364 +++ controllers/gameController.js | 374 +++ controllers/gradeController.js | 264 ++ controllers/parentTaskController.js | 391 +++ controllers/roomController.js | 219 ++ controllers/schoolController.js | 302 ++ controllers/studentController.js | 211 ++ controllers/subjectController.js | 343 +++ controllers/subscriptionController.js | 253 ++ controllers/teacherController.js | 274 ++ controllers/trainingController.js | 318 ++ controllers/userController.js | 495 +++ middleware/errorHandler.js | 144 + models/AcademicYear.js | 19 + models/AttendanceDaily.js | 22 + models/AttendanceLog.js | 90 + models/AuditLog.js | 35 + models/Chapter.js | 69 + models/Class.js | 22 + models/ClassSchedule.js | 22 + models/Game.js | 101 + models/Grade.js | 100 + models/GradeCategory.js | 19 + models/GradeHistory.js | 21 + models/GradeItem.js | 29 + models/GradeSummary.js | 20 + models/LeaveRequest.js | 22 + models/Lesson.js | 101 + models/Message.js | 36 + models/Notification.js | 40 + models/NotificationLog.js | 33 + models/ParentAssignedTask.js | 95 + models/ParentStudentMap.js | 24 + models/Permission.js | 24 + models/Role.js | 64 + models/RolePermission.js | 18 + models/Room.js | 20 + models/School.js | 83 + models/StaffAchievement.js | 92 + models/StaffContract.js | 21 + models/StaffTrainingAssignment.js | 90 + models/StudentDetail.js | 24 + models/Subject.js | 29 + models/SubscriptionPlan.js | 69 + models/TeacherDetail.js | 31 + models/UserAssignment.js | 27 + models/UserProfile.js | 79 + models/UserSubscription.js | 87 + models/UsersAuth.js | 112 + models/index.js | 271 ++ package.json | 49 + public/js/login.js | 252 ++ public/login.html | 290 ++ routes/academicYearRoutes.js | 33 + routes/attendanceRoutes.js | 27 + routes/authRoutes.js | 24 + routes/chapterRoutes.js | 28 + routes/classRoutes.js | 33 + routes/gameRoutes.js | 34 + routes/gradeRoutes.js | 33 + routes/parentTaskRoutes.js | 30 + routes/roomRoutes.js | 30 + routes/schoolRoutes.js | 73 + routes/studentRoutes.js | 27 + routes/subjectRoutes.js | 36 + routes/subscriptionRoutes.js | 24 + routes/teacherRoutes.js | 30 + routes/trainingRoutes.js | 27 + routes/userRoutes.js | 41 + scripts/add-senaai-center.js | 79 + scripts/clear-teacher-cache.js | 50 + scripts/create-test-user.js | 111 + scripts/drop-grade-tables.js | 22 + scripts/dump-and-sync-teachers.js | 267 ++ scripts/export-teachers-info.js | 193 ++ scripts/generate-models.js | 305 ++ scripts/import-apdinh-students.js | 315 ++ scripts/import-foreign-teachers.js | 201 ++ scripts/import-schools.js | 183 ++ scripts/sync-database.js | 67 + scripts/sync-teacher-profiles.js | 91 + scripts/sync-user-tables.js | 58 + scripts/test-api-teachers.js | 76 + scripts/test-profile-logic.js | 85 + scripts/test-student-profile-join.js | 105 + scripts/test-teacher-profile.js | 267 ++ scripts/verify-schools.js | 175 ++ server.js | 132 + services/teacherProfileService.js | 329 ++ sync-database.js | 40 + workers/databaseWriteWorker.js | 199 ++ yarn.lock | 4090 +++++++++++++++++++++++++ 103 files changed, 16929 insertions(+), 2 deletions(-) create mode 100644 LOGIN_GUIDE.md delete mode 100644 README.md create mode 100644 app.js create mode 100644 config/bullmq.js create mode 100644 config/config.json create mode 100644 config/database.js create mode 100644 config/redis.js create mode 100644 controllers/academicYearController.js create mode 100644 controllers/attendanceController.js create mode 100644 controllers/authController.js create mode 100644 controllers/chapterController.js create mode 100644 controllers/classController.js create mode 100644 controllers/gameController.js create mode 100644 controllers/gradeController.js create mode 100644 controllers/parentTaskController.js create mode 100644 controllers/roomController.js create mode 100644 controllers/schoolController.js create mode 100644 controllers/studentController.js create mode 100644 controllers/subjectController.js create mode 100644 controllers/subscriptionController.js create mode 100644 controllers/teacherController.js create mode 100644 controllers/trainingController.js create mode 100644 controllers/userController.js create mode 100644 middleware/errorHandler.js create mode 100644 models/AcademicYear.js create mode 100644 models/AttendanceDaily.js create mode 100644 models/AttendanceLog.js create mode 100644 models/AuditLog.js create mode 100644 models/Chapter.js create mode 100644 models/Class.js create mode 100644 models/ClassSchedule.js create mode 100644 models/Game.js create mode 100644 models/Grade.js create mode 100644 models/GradeCategory.js create mode 100644 models/GradeHistory.js create mode 100644 models/GradeItem.js create mode 100644 models/GradeSummary.js create mode 100644 models/LeaveRequest.js create mode 100644 models/Lesson.js create mode 100644 models/Message.js create mode 100644 models/Notification.js create mode 100644 models/NotificationLog.js create mode 100644 models/ParentAssignedTask.js create mode 100644 models/ParentStudentMap.js create mode 100644 models/Permission.js create mode 100644 models/Role.js create mode 100644 models/RolePermission.js create mode 100644 models/Room.js create mode 100644 models/School.js create mode 100644 models/StaffAchievement.js create mode 100644 models/StaffContract.js create mode 100644 models/StaffTrainingAssignment.js create mode 100644 models/StudentDetail.js create mode 100644 models/Subject.js create mode 100644 models/SubscriptionPlan.js create mode 100644 models/TeacherDetail.js create mode 100644 models/UserAssignment.js create mode 100644 models/UserProfile.js create mode 100644 models/UserSubscription.js create mode 100644 models/UsersAuth.js create mode 100644 models/index.js create mode 100644 package.json create mode 100644 public/js/login.js create mode 100644 public/login.html create mode 100644 routes/academicYearRoutes.js create mode 100644 routes/attendanceRoutes.js create mode 100644 routes/authRoutes.js create mode 100644 routes/chapterRoutes.js create mode 100644 routes/classRoutes.js create mode 100644 routes/gameRoutes.js create mode 100644 routes/gradeRoutes.js create mode 100644 routes/parentTaskRoutes.js create mode 100644 routes/roomRoutes.js create mode 100644 routes/schoolRoutes.js create mode 100644 routes/studentRoutes.js create mode 100644 routes/subjectRoutes.js create mode 100644 routes/subscriptionRoutes.js create mode 100644 routes/teacherRoutes.js create mode 100644 routes/trainingRoutes.js create mode 100644 routes/userRoutes.js create mode 100644 scripts/add-senaai-center.js create mode 100644 scripts/clear-teacher-cache.js create mode 100644 scripts/create-test-user.js create mode 100644 scripts/drop-grade-tables.js create mode 100644 scripts/dump-and-sync-teachers.js create mode 100644 scripts/export-teachers-info.js create mode 100644 scripts/generate-models.js create mode 100644 scripts/import-apdinh-students.js create mode 100644 scripts/import-foreign-teachers.js create mode 100644 scripts/import-schools.js create mode 100644 scripts/sync-database.js create mode 100644 scripts/sync-teacher-profiles.js create mode 100644 scripts/sync-user-tables.js create mode 100644 scripts/test-api-teachers.js create mode 100644 scripts/test-profile-logic.js create mode 100644 scripts/test-student-profile-join.js create mode 100644 scripts/test-teacher-profile.js create mode 100644 scripts/verify-schools.js create mode 100644 server.js create mode 100644 services/teacherProfileService.js create mode 100644 sync-database.js create mode 100644 workers/databaseWriteWorker.js create mode 100644 yarn.lock diff --git a/LOGIN_GUIDE.md b/LOGIN_GUIDE.md new file mode 100644 index 0000000..7d57f74 --- /dev/null +++ b/LOGIN_GUIDE.md @@ -0,0 +1,344 @@ +# 🔐 Hướng Dẫn Hệ Thống Đăng Nhập + +## 📋 Mô Tả Quy Trình Đăng Nhập + +### 1️⃣ Kiến Trúc Hệ Thống + +``` +┌─────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Browser │────▶│ API Server │────▶│ Database │ +│ (login.html)│◀────│ (Express) │◀────│ (MySQL) │ +└─────────────┘ └──────────────┘ └──────────────┘ + │ + ▼ + ┌──────────────┐ + │ Redis │ + │ (Cache) │ + └──────────────┘ +``` + +### 2️⃣ Quy Trình Đăng Nhập Chi Tiết + +#### **Bước 1: User Nhập Thông Tin** +- Frontend (login.html) hiển thị form đăng nhập +- User nhập `username` hoặc `email` và `password` +- Click button "Đăng nhập" + +#### **Bước 2: Gửi Request Đến Server** +```javascript +POST /api/users/login +Content-Type: application/json + +{ + "username": "admin", // hoặc email + "password": "admin123" +} +``` + +#### **Bước 3: Server Xác Thực** + +**3.1. Tìm User trong Database** +```javascript +// Tìm user theo username HOẶC email +UsersAuth.findOne({ + where: { + OR: [ + { username: username }, + { email: username } + ] + } +}) +``` + +**3.2. Kiểm Tra Các Điều Kiện** +- ❌ User không tồn tại → Trả về lỗi 401 +- ❌ Tài khoản bị khóa (`is_locked = true`) → Trả về lỗi 403 +- ❌ Tài khoản không active (`is_active = false`) → Trả về lỗi 403 + +**3.3. Verify Password** +```javascript +// Password được hash theo công thức: +// hash = bcrypt(password + salt) + +const passwordMatch = await bcrypt.compare( + password + user.salt, + user.password_hash +); +``` + +**3.4. Xử Lý Kết Quả** + +**❌ Nếu Password Sai:** +- Tăng `failed_login_attempts` lên 1 +- Nếu thất bại ≥ 5 lần: + - Khóa tài khoản (`is_locked = true`) + - Set `locked_until` = hiện tại + 30 phút +- Trả về lỗi 401 + +**✅ Nếu Password Đúng:** +- Reset `failed_login_attempts = 0` +- Tăng `login_count` lên 1 +- Cập nhật `last_login` = thời gian hiện tại +- Cập nhật `last_login_ip` = IP của client +- Tạo `session_id` mới (UUID) + +#### **Bước 4: Tạo JWT Token** +```javascript +const token = jwt.sign( + { + userId: user.id, + username: user.username, + email: user.email, + sessionId: sessionId + }, + JWT_SECRET, + { expiresIn: '24h' } +); +``` + +#### **Bước 5: Trả Response Về Client** +```json +{ + "success": true, + "message": "Đăng nhập thành công", + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": "uuid-here", + "username": "admin", + "email": "admin@senaai.tech", + "profile": { ... }, + "last_login": "2026-01-19T...", + "login_count": 42 + } + } +} +``` + +#### **Bước 6: Client Lưu Token** +```javascript +// Lưu token vào localStorage +localStorage.setItem('token', data.data.token); +``` + +### 3️⃣ Sử Dụng Token Để Xác Thực + +**Các Request Tiếp Theo:** +```javascript +fetch('/api/users/profile', { + headers: { + 'Authorization': 'Bearer ' + token + } +}) +``` + +**Server Verify Token:** +```javascript +POST /api/users/verify-token +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### 4️⃣ Đăng Xuất + +```javascript +POST /api/users/logout +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + +// Server sẽ: +// 1. Xóa current_session_id trong database +// 2. Token sẽ không còn valid nữa +``` + +## 🔒 Bảo Mật + +### Password Security +- **Salt**: Mỗi user có salt riêng (16 bytes random) +- **Hash Algorithm**: bcrypt với cost factor = 10 +- **Formula**: `hash = bcrypt(password + salt, 10)` + +### Token Security +- **Algorithm**: JWT với HS256 +- **Expiration**: 24 giờ +- **Secret Key**: Lưu trong environment variable +- **Session Tracking**: Mỗi lần login tạo session_id mới + +### Brute Force Protection +- Khóa tài khoản sau 5 lần đăng nhập sai +- Thời gian khóa: 30 phút +- Tự động mở khóa sau khi hết thời gian + +## 📊 Database Schema + +### Table: `users_auth` +```sql +- id (UUID, PK) +- username (VARCHAR, UNIQUE) +- email (VARCHAR, UNIQUE) +- password_hash (VARCHAR) +- salt (VARCHAR) +- current_session_id (VARCHAR) +- last_login (DATETIME) +- last_login_ip (VARCHAR) +- login_count (INT) +- failed_login_attempts (INT) +- is_active (BOOLEAN) +- is_locked (BOOLEAN) +- locked_until (DATETIME) +``` + +## 🚀 API Endpoints + +### Authentication +| Method | Endpoint | Mô Tả | +|--------|----------|-------| +| POST | `/api/users/login` | Đăng nhập | +| POST | `/api/users/register` | Đăng ký tài khoản mới | +| POST | `/api/users/verify-token` | Xác thực token | +| POST | `/api/users/logout` | Đăng xuất | + +### User Management +| Method | Endpoint | Mô Tả | +|--------|----------|-------| +| GET | `/api/users` | Lấy danh sách users | +| GET | `/api/users/:id` | Lấy thông tin user theo ID | +| POST | `/api/users` | Tạo user mới | +| PUT | `/api/users/:id` | Cập nhật user | +| DELETE | `/api/users/:id` | Xóa user (soft delete) | + +## 🧪 Testing + +### 1. Tạo Test Users +```bash +node scripts/create-test-user.js +``` + +### 2. Mở Login Page +``` +http://localhost:4000/login.html +``` + +### 3. Test Login +**Test Accounts:** +- **Admin**: `admin` / `admin123` +- **Teacher**: `teacher1` / `teacher123` +- **Student**: `student1` / `student123` + +### 4. Test Với cURL + +**Login:** +```bash +curl -X POST http://localhost:4000/api/users/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' +``` + +**Verify Token:** +```bash +curl -X POST http://localhost:4000/api/users/verify-token \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +``` + +**Logout:** +```bash +curl -X POST http://localhost:4000/api/users/logout \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +``` + +## 🎯 Luồng Xử Lý (Flow Diagram) + +``` +User Input + │ + ▼ +[Frontend] login.html + │ POST /api/users/login + │ { username, password } + ▼ +[Backend] userController.login() + │ + ├──▶ Tìm user trong DB + │ ├─ Không tìm thấy → 401 Unauthorized + │ └─ Tìm thấy ✓ + │ + ├──▶ Kiểm tra is_locked + │ ├─ Bị khóa → 403 Forbidden + │ └─ Không khóa ✓ + │ + ├──▶ Kiểm tra is_active + │ ├─ Không active → 403 Forbidden + │ └─ Active ✓ + │ + ├──▶ Verify password với bcrypt + │ ├─ Sai → Tăng failed_attempts + │ │ ├─ ≥5 lần → Khóa tài khoản + │ │ └─ 401 Unauthorized + │ └─ Đúng ✓ + │ + ├──▶ Cập nhật thông tin login + │ ├─ Reset failed_attempts = 0 + │ ├─ Tăng login_count + │ ├─ Cập nhật last_login + │ ├─ Cập nhật last_login_ip + │ └─ Tạo session_id mới + │ + ├──▶ Tạo JWT token + │ └─ Expire sau 24h + │ + └──▶ Return response + └─ { token, user } + │ + ▼ +[Frontend] Nhận response + │ + ├──▶ Lưu token vào localStorage + ├──▶ Hiển thị thông báo thành công + └──▶ Sử dụng token cho các request sau +``` + +## 💡 Best Practices + +### Frontend +- ✅ Lưu token trong localStorage +- ✅ Gửi token trong header: `Authorization: Bearer TOKEN` +- ✅ Xử lý token expired (redirect về login) +- ✅ Clear token khi logout + +### Backend +- ✅ Validate input (username, password) +- ✅ Rate limiting để chống brute force +- ✅ Log các lần đăng nhập thất bại +- ✅ Use HTTPS trong production +- ✅ Rotate JWT secret định kỳ + +### Database +- ✅ Index trên username, email +- ✅ Không bao giờ lưu plain password +- ✅ Use UUID cho ID +- ✅ Soft delete (is_active) thay vì DELETE + +## 🔍 Troubleshooting + +### "401 Unauthorized" +- Kiểm tra username/password có đúng không +- Kiểm tra user có tồn tại trong DB không + +### "403 Forbidden - Account Locked" +- Tài khoản bị khóa do nhập sai password ≥5 lần +- Đợi 30 phút hoặc admin mở khóa + +### "Token Invalid" +- Token đã expire (>24h) +- Session đã bị logout +- Đăng nhập lại để lấy token mới + +## 📚 Dependencies + +- `bcrypt`: Hash passwords +- `jsonwebtoken`: Tạo và verify JWT tokens +- `crypto`: Generate salt và secrets +- `sequelize`: ORM for MySQL +- `express`: Web framework + +--- + +**Created by Sena Team** 🚀 diff --git a/README.md b/README.md deleted file mode 100644 index 85d95ba..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# sena_db_api_layer - diff --git a/app.js b/app.js new file mode 100644 index 0000000..b23a9bb --- /dev/null +++ b/app.js @@ -0,0 +1,244 @@ +const express = require('express'); +const helmet = require('helmet'); +const cors = require('cors'); +const compression = require('compression'); +const morgan = require('morgan'); +require('express-async-errors'); // Handle async errors +const config = require('./config/config.json'); + +// Import configurations +const { initializeDatabase } = require('./config/database'); +const { setupRelationships } = require('./models'); +const { redisClient } = require('./config/redis'); + +// Import middleware +const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); + +// Import routes +const authRoutes = require('./routes/authRoutes'); +const schoolRoutes = require('./routes/schoolRoutes'); +const classRoutes = require('./routes/classRoutes'); +const academicYearRoutes = require('./routes/academicYearRoutes'); +const subjectRoutes = require('./routes/subjectRoutes'); +const userRoutes = require('./routes/userRoutes'); +const studentRoutes = require('./routes/studentRoutes'); +const teacherRoutes = require('./routes/teacherRoutes'); +const roomRoutes = require('./routes/roomRoutes'); +const attendanceRoutes = require('./routes/attendanceRoutes'); +const gradeRoutes = require('./routes/gradeRoutes'); +const subscriptionRoutes = require('./routes/subscriptionRoutes'); +const trainingRoutes = require('./routes/trainingRoutes'); +const parentTaskRoutes = require('./routes/parentTaskRoutes'); +const chapterRoutes = require('./routes/chapterRoutes'); +const gameRoutes = require('./routes/gameRoutes'); + +/** + * Initialize Express Application + */ +const app = express(); + +/** + * Security Middleware + */ +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:", "https:"], + connectSrc: ["'self'"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + mediaSrc: ["'self'"], + frameSrc: ["'none'"], + }, + }, +})); +app.use(cors({ + origin: config.cors.origin, + credentials: true, +})); + +/** + * Body Parsing Middleware + */ +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +/** + * Static Files Middleware + */ +app.use(express.static('public')); + +/** + * Compression Middleware + */ +app.use(compression()); + +/** + * Logging Middleware + */ +if (config.server.env === 'development') { + app.use(morgan('dev')); +} else { + app.use(morgan('combined')); +} + +/** + * Health Check Endpoint + */ +app.get('/health', async (req, res) => { + try { + // Check database connection + await require('./config/database').testConnection(); + + // Check Redis connection + await redisClient.ping(); + + res.json({ + success: true, + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + environment: config.server.env, + services: { + database: 'connected', + redis: 'connected', + }, + }); + } catch (error) { + res.status(503).json({ + success: false, + status: 'unhealthy', + error: error.message, + }); + } +}); + +/** + * API Info Endpoint + */ +app.get('/api', (req, res) => { + res.json({ + success: true, + name: 'Sena School Management API', + version: '1.0.0', + description: 'API for managing 200 schools with Redis caching and BullMQ job processing', + enauth: '/api/auth', + dpoints: { + schools: '/api/schools', + classes: '/api/classes', + academicYears: '/api/academic-years', + subjects: '/api/subjects', + users: '/api/users', + students: '/api/students', + teachers: '/api/teachers', + rooms: '/api/rooms', + attendance: '/api/attendance', + grades: '/api/grades', + subscriptions: '/api/subscriptions', + training: '/api/training', + parentTasks: '/api/parent-tasks', + chapters: '/api/chapters', + games: '/api/games', + }, + documentation: '/api/docs', + }); +}); + +/** + * API Routes + */ +app.use('/api/auth', authRoutes); +app.use('/api/schools', schoolRoutes); +app.use('/api/classes', classRoutes); +app.use('/api/academic-years', academicYearRoutes); +app.use('/api/subjects', subjectRoutes); +app.use('/api/users', userRoutes); +app.use('/api/students', studentRoutes); +app.use('/api/teachers', teacherRoutes); +app.use('/api/rooms', roomRoutes); +app.use('/api/attendance', attendanceRoutes); +app.use('/api/grades', gradeRoutes); +app.use('/api/subscriptions', subscriptionRoutes); +app.use('/api/training', trainingRoutes); +app.use('/api/parent-tasks', parentTaskRoutes); +app.use('/api/chapters', chapterRoutes); +app.use('/api/games', gameRoutes); + +/** + * Queue Status Endpoint + */ +app.get('/api/queues/status', async (req, res) => { + try { + const { getQueueMetrics, QueueNames } = require('./config/bullmq'); + + const metrics = await Promise.all( + Object.values(QueueNames).map(queueName => getQueueMetrics(queueName)) + ); + + res.json({ + success: true, + data: metrics, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error.message, + }); + } +}); + +/** + * Cache Stats Endpoint + */ +app.get('/api/cache/stats', async (req, res) => { + try { + const info = await redisClient.info('stats'); + const dbSize = await redisClient.dbsize(); + + res.json({ + success: true, + data: { + dbSize, + stats: info, + }, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error.message, + }); + } +}); + +/** + * 404 Handler + */ +app.use(notFoundHandler); + +/** + * Global Error Handler + */ +app.use(errorHandler); + +/** + * Initialize Application + */ +const initializeApp = async () => { + try { + // Initialize database connection + await initializeDatabase(); + + // Setup model relationships + setupRelationships(); + + console.log('✅ Application initialized successfully'); + } catch (error) { + console.error('❌ Application initialization failed:', error.message); + throw error; + } +}; + +module.exports = { app, initializeApp }; diff --git a/config/bullmq.js b/config/bullmq.js new file mode 100644 index 0000000..c00ef9c --- /dev/null +++ b/config/bullmq.js @@ -0,0 +1,256 @@ +const { Queue, Worker } = require('bullmq'); +const Redis = require('ioredis'); +const config = require('./config.json'); + +/** + * BullMQ Connection Configuration - Direct Redis connection + */ +const redisConfig = config.redis; + +// Connection options for BullMQ (BullMQ will create connections using these options) +const connectionOptions = { + host: redisConfig.cluster[0].host, + port: redisConfig.cluster[0].port, + password: redisConfig.password, + db: redisConfig.db, + maxRetriesPerRequest: null, + enableReadyCheck: false, +}; + +// Create a shared Redis connection for direct Redis operations +const bullMQConnection = new Redis(connectionOptions); + +console.log('📊 BullMQ Redis Connection:', { + host: connectionOptions.host, + port: connectionOptions.port, + db: connectionOptions.db, +}); + +bullMQConnection.on('connect', () => { + console.log('✅ BullMQ Redis connected'); +}); + +bullMQConnection.on('error', (err) => { + console.error('❌ BullMQ Redis error:', err.message); +}); + +/** + * Default job options + */ +const defaultJobOptions = { + attempts: 3, + backoff: { + type: 'exponential', + delay: 2000, + }, + removeOnComplete: { + age: 24 * 3600, // Keep completed jobs for 24 hours + count: 1000, + }, + removeOnFail: { + age: 7 * 24 * 3600, // Keep failed jobs for 7 days + }, +}; + +/** + * Queue definitions for different operations + */ +const QueueNames = { + DATABASE_WRITE: 'database-write', + NOTIFICATION: 'notification', + ATTENDANCE_PROCESS: 'attendance-process', + GRADE_CALCULATION: 'grade-calculation', + REPORT_GENERATION: 'report-generation', +}; + +/** + * Create queues with connection options + */ +const queues = {}; + +Object.values(QueueNames).forEach(queueName => { + queues[queueName] = new Queue(queueName, { + connection: connectionOptions, // Use connection options, not instance + prefix: process.env.BULLMQ_PREFIX || 'vcb', + defaultJobOptions, + }); + + queues[queueName].on('error', (error) => { + console.error(`❌ Queue ${queueName} error:`, error.message); + }); + + console.log(`✅ Queue ${queueName} initialized`); +}); + +/** + * Add job to database write queue + * @param {string} operation - Operation type: 'create', 'update', 'delete' + * @param {string} model - Model name + * @param {object} data - Data to be written + * @param {object} options - Additional options + */ +const addDatabaseWriteJob = async (operation, model, data, options = {}) => { + try { + const job = await queues[QueueNames.DATABASE_WRITE].add( + `${operation}-${model}`, + { + operation, + model, + data, + timestamp: new Date().toISOString(), + userId: options.userId, + ...options, + }, + { + priority: options.priority || 5, + delay: options.delay || 0, + } + ); + + console.log(`✅ Database write job added: ${job.id} (${operation} ${model})`); + return job; + } catch (error) { + console.error(`❌ Error adding database write job:`, error.message); + throw error; + } +}; + +/** + * Add notification job + */ +const addNotificationJob = async (type, recipients, content, options = {}) => { + try { + const job = await queues[QueueNames.NOTIFICATION].add( + `send-${type}`, + { + type, + recipients, + content, + timestamp: new Date().toISOString(), + ...options, + }, + { + priority: options.priority || 5, + } + ); + + console.log(`✅ Notification job added: ${job.id}`); + return job; + } catch (error) { + console.error(`❌ Error adding notification job:`, error.message); + throw error; + } +}; + +/** + * Add attendance processing job + */ +const addAttendanceProcessJob = async (schoolId, date, options = {}) => { + try { + const job = await queues[QueueNames.ATTENDANCE_PROCESS].add( + 'process-attendance', + { + schoolId, + date, + timestamp: new Date().toISOString(), + ...options, + }, + { + priority: options.priority || 3, + } + ); + + console.log(`✅ Attendance process job added: ${job.id}`); + return job; + } catch (error) { + console.error(`❌ Error adding attendance process job:`, error.message); + throw error; + } +}; + +/** + * Add grade calculation job + */ +const addGradeCalculationJob = async (studentId, academicYearId, options = {}) => { + try { + const job = await queues[QueueNames.GRADE_CALCULATION].add( + 'calculate-grades', + { + studentId, + academicYearId, + timestamp: new Date().toISOString(), + ...options, + }, + { + priority: options.priority || 4, + } + ); + + console.log(`✅ Grade calculation job added: ${job.id}`); + return job; + } catch (error) { + console.error(`❌ Error adding grade calculation job:`, error.message); + throw error; + } +}; + +/** + * Get queue metrics + */ +const getQueueMetrics = async (queueName) => { + try { + const queue = queues[queueName]; + if (!queue) { + throw new Error(`Queue ${queueName} not found`); + } + + const [waiting, active, completed, failed, delayed] = await Promise.all([ + queue.getWaitingCount(), + queue.getActiveCount(), + queue.getCompletedCount(), + queue.getFailedCount(), + queue.getDelayedCount(), + ]); + + return { + queueName, + waiting, + active, + completed, + failed, + delayed, + total: waiting + active + completed + failed + delayed, + }; + } catch (error) { + console.error(`❌ Error getting queue metrics:`, error.message); + throw error; + } +}; + +/** + * Close all queues + */ +const closeQueues = async () => { + try { + await Promise.all( + Object.values(queues).map(queue => queue.close()) + ); + console.log('✅ All BullMQ queues closed'); + } catch (error) { + console.error('❌ Error closing queues:', error.message); + } +}; + +module.exports = { + queues, + QueueNames, + addDatabaseWriteJob, + addNotificationJob, + addAttendanceProcessJob, + addGradeCalculationJob, + getQueueMetrics, + closeQueues, + bullMQConnection, + connectionOptions, + defaultJobOptions, +}; diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..727c10b --- /dev/null +++ b/config/config.json @@ -0,0 +1,40 @@ +{ + "database": { + "host": "senaai.tech", + "port": 11001, + "username": "root", + "password": "Sena@2026!", + "database": "sena_school_db", + "dialect": "mysql", + "pool": { + "max": 20, + "min": 5, + "acquire": 60000, + "idle": 10000 + }, + "logging": false, + "timezone": "+07:00" + }, + "redis": { + "cluster": [ + { + "host": "senaai.tech", + "port": 11010 + }, + { + "host": "senaai.tech", + "port": 11011 + } + ], + "password": "Sena@2026!", + "db": 0, + "keyPrefix": "sena:" + }, + "server": { + "port" : 4000, + "env": "production" + }, + "cors": { + "origin": "*" + } +} diff --git a/config/database.js b/config/database.js new file mode 100644 index 0000000..7d81471 --- /dev/null +++ b/config/database.js @@ -0,0 +1,98 @@ +const { Sequelize } = require('sequelize'); +const config = require('./config.json'); + +/** + * MySQL Connection Configuration via senaai.tech + * Direct connection to MySQL server (port 11001) + */ +const dbConfig = config.database; + +const sequelize = new Sequelize( + dbConfig.database, + dbConfig.username, + dbConfig.password, + { + host: dbConfig.host, + port: dbConfig.port, + dialect: dbConfig.dialect, + dialectOptions: { + connectTimeout: 60000, // 60 seconds for slow networks + }, + + // Connection Pool Configuration + pool: dbConfig.pool, + + // Logging + logging: dbConfig.logging, + + // Query options + define: { + timestamps: true, + underscored: true, + freezeTableName: true, + }, + + // Timezone + timezone: dbConfig.timezone, + + // Retry configuration + retry: { + max: 3, + timeout: 3000, + }, + } +); + +/** + * Test database connection + */ +const testConnection = async () => { + try { + await sequelize.authenticate(); + console.log(`✅ MySQL connection established successfully to ${dbConfig.host}:${dbConfig.port}`); + return true; + } catch (error) { + console.error('❌ Unable to connect to MySQL:', error.message); + return false; + } +}; + +/** + * Initialize database models + */ +const initializeDatabase = async () => { + try { + // Test connection first + const isConnected = await testConnection(); + if (!isConnected) { + throw new Error('Failed to connect to database'); + } + + // Note: Auto-sync disabled in production for safety + console.log('✅ Database connection ready'); + + return sequelize; + } catch (error) { + console.error('❌ Database initialization failed:', error.message); + throw error; + } +}; + +/** + * Close database connection + */ +const closeConnection = async () => { + try { + await sequelize.close(); + console.log('✅ Database connection closed'); + } catch (error) { + console.error('❌ Error closing database connection:', error.message); + } +}; + +module.exports = { + sequelize, + testConnection, + initializeDatabase, + closeConnection, +}; diff --git a/config/redis.js b/config/redis.js new file mode 100644 index 0000000..fc39811 --- /dev/null +++ b/config/redis.js @@ -0,0 +1,169 @@ +const Redis = require('ioredis'); +const config = require('./config.json'); + +/** + * Redis Connection - Direct connection to master + * For Sentinel HA from external clients, we connect directly to the master port + * and rely on manual failover by trying both ports + */ +const redisConfig = config.redis; + +// Direct connection to master (port 11010) +const redisClient = new Redis({ + host: redisConfig.cluster[0].host, + port: redisConfig.cluster[0].port, + password: redisConfig.password, + db: redisConfig.db, + keyPrefix: redisConfig.keyPrefix, + connectTimeout: 10000, + + retryStrategy: (times) => { + if (times > 10) { + console.log(`⚠️ Redis retry exhausted after ${times} attempts`); + return null; + } + const delay = Math.min(times * 100, 3000); + console.log(`🔄 Redis retry attempt ${times}, delay ${delay}ms`); + return delay; + }, + + // Reconnect on READONLY error (slave promoted) + reconnectOnError: (err) => { + if (err.message.includes('READONLY')) { + console.log('⚠️ READONLY error detected - slave may have been promoted'); + return true; + } + return false; + }, + + enableOfflineQueue: true, + maxRetriesPerRequest: null, + enableReadyCheck: true, +}); + +/** + * Redis Event Handlers + */ +redisClient.on('connect', () => { + console.log('✅ Redis client connected'); +}); + +redisClient.on('ready', () => { + console.log('✅ Redis client ready'); +}); + +redisClient.on('error', (err) => { + console.error('❌ Redis error:', err.message); +}); + +redisClient.on('close', () => { + console.log('⚠️ Redis client closed'); +}); + +redisClient.on('reconnecting', () => { + console.log('🔄 Redis client reconnecting...'); +}); + +/** + * Cache utility functions + */ +const cacheUtils = { + /** + * Get value from cache + */ + get: async (key) => { + try { + const value = await redisClient.get(key); + return value ? JSON.parse(value) : null; + } catch (error) { + console.error(`Error getting cache for key ${key}:`, error.message); + return null; + } + }, + + /** + * Set value to cache with TTL + */ + set: async (key, value, ttl = 3600) => { + try { + const serialized = JSON.stringify(value); + await redisClient.setex(key, ttl, serialized); + return true; + } catch (error) { + console.error(`Error setting cache for key ${key}:`, error.message); + return false; + } + }, + + /** + * Delete cache by key + */ + delete: async (key) => { + try { + await redisClient.del(key); + return true; + } catch (error) { + console.error(`Error deleting cache for key ${key}:`, error.message); + return false; + } + }, + + /** + * Delete cache by pattern + */ + deletePattern: async (pattern) => { + try { + const keys = await redisClient.keys(pattern); + if (keys.length > 0) { + await redisClient.del(...keys); + } + return keys.length; + } catch (error) { + console.error(`Error deleting cache pattern ${pattern}:`, error.message); + return 0; + } + }, + + /** + * Check if key exists + */ + exists: async (key) => { + try { + const result = await redisClient.exists(key); + return result === 1; + } catch (error) { + console.error(`Error checking cache existence for key ${key}:`, error.message); + return false; + } + }, + + /** + * Get TTL for key + */ + ttl: async (key) => { + try { + return await redisClient.ttl(key); + } catch (error) { + console.error(`Error getting TTL for key ${key}:`, error.message); + return -1; + } + }, +}; + +/** + * Close Redis connection + */ +const closeRedisConnection = async () => { + try { + await redisClient.quit(); + console.log('✅ Redis connection closed gracefully'); + } catch (error) { + console.error('❌ Error closing Redis connection:', error.message); + } +}; + +module.exports = { + redisClient, + cacheUtils, + closeRedisConnection, +}; diff --git a/controllers/academicYearController.js b/controllers/academicYearController.js new file mode 100644 index 0000000..8ae7bbe --- /dev/null +++ b/controllers/academicYearController.js @@ -0,0 +1,270 @@ +const { AcademicYear } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob } = require('../config/bullmq'); + +/** + * Academic Year Controller - Quản lý năm học + */ +class AcademicYearController { + /** + * Get all academic years with pagination and caching + */ + async getAllAcademicYears(req, res, next) { + try { + const { page = 1, limit = 20, is_current } = req.query; + const offset = (page - 1) * limit; + + // Generate cache key + const cacheKey = `academic_years:list:${page}:${limit}:${is_current || 'all'}`; + + // Try to get from cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Build query conditions + const where = {}; + if (is_current !== undefined) where.is_current = is_current === 'true'; + + // Query from database (through ProxySQL) + const { count, rows } = await AcademicYear.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['start_date', 'DESC']], + }); + + const result = { + academicYears: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + // Cache the result + await cacheUtils.set(cacheKey, result, 3600); // Cache for 1 hour + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get academic year by ID with caching + */ + async getAcademicYearById(req, res, next) { + try { + const { id } = req.params; + const cacheKey = `academic_year:${id}`; + + // Try cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Query from database + const academicYear = await AcademicYear.findByPk(id); + + if (!academicYear) { + return res.status(404).json({ + success: false, + message: 'Academic year not found', + }); + } + + // Cache the result + await cacheUtils.set(cacheKey, academicYear, 7200); // Cache for 2 hours + + res.json({ + success: true, + data: academicYear, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get current academic year with caching + */ + async getCurrentAcademicYear(req, res, next) { + try { + const cacheKey = 'academic_year:current'; + + // Try cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Query from database + const academicYear = await AcademicYear.findOne({ + where: { is_current: true }, + }); + + if (!academicYear) { + return res.status(404).json({ + success: false, + message: 'No current academic year found', + }); + } + + // Cache the result + await cacheUtils.set(cacheKey, academicYear, 3600); // Cache for 1 hour + + res.json({ + success: true, + data: academicYear, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Create new academic year (async via BullMQ) + */ + async createAcademicYear(req, res, next) { + try { + const academicYearData = req.body; + + // Add to job queue for async processing + const job = await addDatabaseWriteJob('create', 'AcademicYear', academicYearData); + + // Invalidate related caches + await cacheUtils.deletePattern('academic_years:list:*'); + await cacheUtils.delete('academic_year:current'); + + res.status(202).json({ + success: true, + message: 'Academic year creation job queued', + jobId: job.id, + data: academicYearData, + }); + } catch (error) { + next(error); + } + } + + /** + * Update academic year (async via BullMQ) + */ + async updateAcademicYear(req, res, next) { + try { + const { id } = req.params; + const updates = req.body; + + // Add to job queue for async processing + const job = await addDatabaseWriteJob('update', 'AcademicYear', { + id, + updates, + }); + + // Invalidate related caches + await cacheUtils.delete(`academic_year:${id}`); + await cacheUtils.deletePattern('academic_years:list:*'); + await cacheUtils.delete('academic_year:current'); + + res.status(202).json({ + success: true, + message: 'Academic year update job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Delete academic year (async via BullMQ) + */ + async deleteAcademicYear(req, res, next) { + try { + const { id } = req.params; + + // Add to job queue for async processing + const job = await addDatabaseWriteJob('delete', 'AcademicYear', { id }); + + // Invalidate related caches + await cacheUtils.delete(`academic_year:${id}`); + await cacheUtils.deletePattern('academic_years:list:*'); + await cacheUtils.delete('academic_year:current'); + + res.status(202).json({ + success: true, + message: 'Academic year deletion job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Set academic year as current + */ + async setCurrentAcademicYear(req, res, next) { + try { + const { id } = req.params; + + // Add to job queue to update current year + const job = await addDatabaseWriteJob('update', 'AcademicYear', { + id, + updates: { is_current: true }, + // Will also set all other years to is_current: false + }); + + // Invalidate all academic year caches + await cacheUtils.deletePattern('academic_year*'); + + res.status(202).json({ + success: true, + message: 'Set current academic year job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Get academic year datatypes + */ + async getAcademicYearDatatypes(req, res, next) { + try { + const datatypes = AcademicYear.rawAttributes; + res.json({ + success: true, + data: datatypes, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new AcademicYearController(); diff --git a/controllers/attendanceController.js b/controllers/attendanceController.js new file mode 100644 index 0000000..60d795e --- /dev/null +++ b/controllers/attendanceController.js @@ -0,0 +1,248 @@ +const { AttendanceLog, AttendanceDaily, UsersAuth, School } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob, addAttendanceProcessJob } = require('../config/bullmq'); + +/** + * Attendance Controller - Quản lý điểm danh + */ +class AttendanceController { + /** + * Get attendance logs with pagination + */ + async getAttendanceLogs(req, res, next) { + try { + const { page = 1, limit = 50, user_id, school_id, date_from, date_to } = req.query; + const offset = (page - 1) * limit; + + const cacheKey = `attendance:logs:${page}:${limit}:${user_id || 'all'}:${school_id || 'all'}:${date_from || ''}:${date_to || ''}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = {}; + if (user_id) where.user_id = user_id; + if (school_id) where.school_id = school_id; + if (date_from && date_to) { + where.check_time = { + [require('sequelize').Op.between]: [date_from, date_to], + }; + } + + const { count, rows } = await AttendanceLog.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + include: [ + { + model: UsersAuth, + as: 'user', + attributes: ['username', 'email'], + }, + { + model: School, + as: 'school', + attributes: ['school_code', 'school_name'], + }, + ], + order: [['check_time', 'DESC']], + }); + + const result = { + logs: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + await cacheUtils.set(cacheKey, result, 300); // Cache 5 minutes + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Create attendance log (QR scan) + */ + async createAttendanceLog(req, res, next) { + try { + const logData = req.body; + + const job = await addDatabaseWriteJob('create', 'AttendanceLog', logData); + + await cacheUtils.deletePattern('attendance:logs:*'); + await cacheUtils.deletePattern('attendance:daily:*'); + + res.status(202).json({ + success: true, + message: 'Attendance log created', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Get daily attendance summary + */ + async getDailyAttendance(req, res, next) { + try { + const { page = 1, limit = 50, school_id, class_id } = req.query; + let { date } = req.query; + const offset = (page - 1) * limit; + + // Use today's date if not provided + if (!date) { + date = new Date().toISOString().split('T')[0]; // Format: YYYY-MM-DD + } + + const cacheKey = `attendance:daily:${school_id || 'all'}:${date}:${class_id || 'all'}:${page}:${limit}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = { attendance_date: date }; + if (school_id) where.school_id = school_id; + if (class_id) where.class_id = class_id; + + const { count, rows } = await AttendanceDaily.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + include: [{ + model: UsersAuth, + as: 'user', + attributes: ['username'], + }], + order: [['status', 'ASC']], + }); + + const result = { + attendance: rows, + date: date, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + await cacheUtils.set(cacheKey, result, 1800); + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Process daily attendance (aggregate from logs) + */ + async processAttendance(req, res, next) { + try { + const { school_id, date } = req.body; + + const job = await addAttendanceProcessJob(school_id, date); + + await cacheUtils.deletePattern('attendance:daily:*'); + + res.status(202).json({ + success: true, + message: 'Attendance processing job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Get attendance statistics + */ + async getAttendanceStats(req, res, next) { + try { + const { school_id, date_from, date_to } = req.query; + const cacheKey = `attendance:stats:${school_id}:${date_from}:${date_to}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const stats = await AttendanceDaily.findAll({ + where: { + school_id, + attendance_date: { + [require('sequelize').Op.between]: [date_from, date_to], + }, + }, + attributes: [ + 'status', + [require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'], + ], + group: ['status'], + raw: true, + }); + + await cacheUtils.set(cacheKey, stats, 3600); + + res.json({ + success: true, + data: stats, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get attendance datatypes + */ + async getAttendanceDatatypes(req, res, next) { + try { + const datatypes = { + AttendanceLog: AttendanceLog.rawAttributes, + AttendanceDaily: AttendanceDaily.rawAttributes, + }; + res.json({ + success: true, + data: datatypes, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new AttendanceController(); diff --git a/controllers/authController.js b/controllers/authController.js new file mode 100644 index 0000000..4875a42 --- /dev/null +++ b/controllers/authController.js @@ -0,0 +1,351 @@ +const { UsersAuth, UserProfile } = require('../models'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); + +// JWT Secret - nên lưu trong environment variable +const JWT_SECRET = process.env.JWT_SECRET || 'sena-secret-key-2026'; +const JWT_EXPIRES_IN = '24h'; + +/** + * Auth Controller - Xác thực và phân quyền + */ +class AuthController { + /** + * Login - Xác thực người dùng + */ + async login(req, res, next) { + try { + const { username, password } = req.body; + + // Validate input + if (!username || !password) { + return res.status(400).json({ + success: false, + message: 'Username và password là bắt buộc', + }); + } + + // Tìm user theo username hoặc email + const user = await UsersAuth.findOne({ + where: { + [require('sequelize').Op.or]: [ + { username: username }, + { email: username }, + ], + }, + include: [{ + model: UserProfile, + as: 'profile', + }], + }); + + if (!user) { + return res.status(401).json({ + success: false, + message: 'Username hoặc password không đúng', + }); + } + + // Kiểm tra tài khoản có bị khóa không + if (user.is_locked) { + if (user.locked_until && new Date() < new Date(user.locked_until)) { + return res.status(403).json({ + success: false, + message: 'Tài khoản bị khóa đến ' + user.locked_until, + }); + } else { + // Mở khóa nếu hết thời gian khóa + await user.update({ + is_locked: false, + locked_until: null, + failed_login_attempts: 0 + }); + } + } + + // Kiểm tra tài khoản có active không + if (!user.is_active) { + return res.status(403).json({ + success: false, + message: 'Tài khoản đã bị vô hiệu hóa', + }); + } + + // Verify password + const passwordMatch = await bcrypt.compare(password + user.salt, user.password_hash); + + if (!passwordMatch) { + // Tăng số lần đăng nhập thất bại + const failedAttempts = user.failed_login_attempts + 1; + const updates = { failed_login_attempts: failedAttempts }; + + // Khóa tài khoản sau 5 lần thất bại + if (failedAttempts >= 5) { + updates.is_locked = true; + updates.locked_until = new Date(Date.now() + 30 * 60 * 1000); // Khóa 30 phút + } + + await user.update(updates); + + return res.status(401).json({ + success: false, + message: 'Username hoặc password không đúng', + attemptsLeft: Math.max(0, 5 - failedAttempts), + }); + } + + // Đăng nhập thành công - Reset failed attempts + const sessionId = crypto.randomUUID(); + const clientIp = req.ip || req.connection.remoteAddress; + + await user.update({ + failed_login_attempts: 0, + login_count: user.login_count + 1, + last_login: new Date(), + last_login_ip: clientIp, + current_session_id: sessionId, + }); + + // Tạo JWT token + const token = jwt.sign( + { + userId: user.id, + username: user.username, + email: user.email, + sessionId: sessionId, + }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); + + // Return success response + res.json({ + success: true, + message: 'Đăng nhập thành công', + data: { + token, + user: { + id: user.id, + username: user.username, + email: user.email, + profile: user.profile, + last_login: user.last_login, + login_count: user.login_count, + }, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * Register - Tạo tài khoản mới + */ + async register(req, res, next) { + try { + const { username, email, password, full_name, phone, school_id } = req.body; + + // Validate input + if (!username || !email || !password) { + return res.status(400).json({ + success: false, + message: 'Username, email và password là bắt buộc', + }); + } + + // Kiểm tra username đã tồn tại + const existingUser = await UsersAuth.findOne({ + where: { + [require('sequelize').Op.or]: [ + { username }, + { email }, + ], + }, + }); + + if (existingUser) { + return res.status(409).json({ + success: false, + message: 'Username hoặc email đã tồn tại', + }); + } + + // Hash password + const salt = crypto.randomBytes(16).toString('hex'); + const passwordHash = await bcrypt.hash(password + salt, 10); + + // Tạo user mới + const newUser = await UsersAuth.create({ + username, + email, + password_hash: passwordHash, + salt, + qr_secret: crypto.randomBytes(32).toString('hex'), + }); + + // Tạo profile nếu có thông tin + if (full_name || phone || school_id) { + await UserProfile.create({ + user_id: newUser.id, + full_name: full_name || username, + phone, + school_id, + }); + } + + res.status(201).json({ + success: true, + message: 'Đăng ký tài khoản thành công', + data: { + id: newUser.id, + username: newUser.username, + email: newUser.email, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * Verify Token - Xác thực JWT token + */ + async verifyToken(req, res, next) { + try { + const token = req.headers.authorization?.replace('Bearer ', ''); + + if (!token) { + return res.status(401).json({ + success: false, + message: 'Token không được cung cấp', + }); + } + + const decoded = jwt.verify(token, JWT_SECRET); + + // Kiểm tra user còn tồn tại và session hợp lệ + const user = await UsersAuth.findByPk(decoded.userId, { + include: [{ + model: UserProfile, + as: 'profile', + }], + }); + + if (!user || !user.is_active) { + return res.status(401).json({ + success: false, + message: 'Token không hợp lệ hoặc tài khoản đã bị vô hiệu hóa', + }); + } + + if (user.current_session_id !== decoded.sessionId) { + return res.status(401).json({ + success: false, + message: 'Phiên đăng nhập đã hết hạn', + }); + } + + res.json({ + success: true, + message: 'Token hợp lệ', + data: { + user: { + id: user.id, + username: user.username, + email: user.email, + profile: user.profile, + }, + }, + }); + } catch (error) { + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ + success: false, + message: 'Token không hợp lệ hoặc đã hết hạn', + }); + } + next(error); + } + } + + /** + * Logout - Đăng xuất + */ + async logout(req, res, next) { + try { + const token = req.headers.authorization?.replace('Bearer ', ''); + + if (!token) { + return res.status(401).json({ + success: false, + message: 'Token không được cung cấp', + }); + } + + const decoded = jwt.verify(token, JWT_SECRET); + + // Xóa session hiện tại + await UsersAuth.update( + { current_session_id: null }, + { where: { id: decoded.userId } } + ); + + res.json({ + success: true, + message: 'Đăng xuất thành công', + }); + } catch (error) { + next(error); + } + } + + /** + * Get current user info from token + */ + async me(req, res, next) { + try { + const token = req.headers.authorization?.replace('Bearer ', ''); + + if (!token) { + return res.status(401).json({ + success: false, + message: 'Token không được cung cấp', + }); + } + + const decoded = jwt.verify(token, JWT_SECRET); + + const user = await UsersAuth.findByPk(decoded.userId, { + include: [{ + model: UserProfile, + as: 'profile', + }], + attributes: { exclude: ['password_hash', 'salt', 'qr_secret'] }, + }); + + if (!user) { + return res.status(404).json({ + success: false, + message: 'User không tồn tại', + }); + } + + res.json({ + success: true, + data: user, + }); + } catch (error) { + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ + success: false, + message: 'Token không hợp lệ hoặc đã hết hạn', + }); + } + next(error); + } + } +} + +module.exports = new AuthController(); diff --git a/controllers/chapterController.js b/controllers/chapterController.js new file mode 100644 index 0000000..4e5a5e4 --- /dev/null +++ b/controllers/chapterController.js @@ -0,0 +1,335 @@ +const { Chapter, Subject, Lesson } = require('../models'); +const { cacheUtils } = require('../config/redis'); + +/** + * Chapter Controller - Quản lý chương học + */ +class ChapterController { + /** + * Get all chapters with pagination + */ + async getAllChapters(req, res, next) { + try { + const { page = 1, limit = 20, subject_id, is_published } = req.query; + const offset = (page - 1) * limit; + + const cacheKey = `chapters:list:${page}:${limit}:${subject_id || 'all'}:${is_published || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = {}; + if (subject_id) where.subject_id = subject_id; + if (is_published !== undefined) where.is_published = is_published === 'true'; + + const { count, rows } = await Chapter.findAndCountAll({ + where, + include: [ + { + model: Subject, + as: 'subject', + attributes: ['id', 'subject_name', 'subject_code'], + }, + ], + limit: parseInt(limit), + offset: parseInt(offset), + order: [['display_order', 'ASC'], ['chapter_number', 'ASC']], + }); + + const result = { + chapters: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + await cacheUtils.set(cacheKey, result, 1800); + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get chapter by ID + */ + async getChapterById(req, res, next) { + try { + const { id } = req.params; + const { include_lessons } = req.query; + + const cacheKey = `chapter:${id}:${include_lessons || 'no'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const includeOptions = [ + { + model: Subject, + as: 'subject', + attributes: ['id', 'subject_name', 'subject_code', 'description'], + }, + ]; + + if (include_lessons === 'true') { + includeOptions.push({ + model: Lesson, + as: 'lessons', + where: { is_published: true }, + required: false, + order: [['display_order', 'ASC']], + }); + } + + const chapter = await Chapter.findByPk(id, { + include: includeOptions, + }); + + if (!chapter) { + return res.status(404).json({ + success: false, + message: 'Chapter not found', + }); + } + + await cacheUtils.set(cacheKey, chapter, 3600); + + res.json({ + success: true, + data: chapter, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Create new chapter + */ + async createChapter(req, res, next) { + try { + const { + subject_id, + chapter_number, + chapter_title, + chapter_description, + duration_minutes, + is_published, + display_order, + } = req.body; + + // Validate required fields + if (!subject_id || !chapter_number || !chapter_title) { + return res.status(400).json({ + success: false, + message: 'subject_id, chapter_number, and chapter_title are required', + }); + } + + // Check if subject exists + const subject = await Subject.findByPk(subject_id); + if (!subject) { + return res.status(404).json({ + success: false, + message: 'Subject not found', + }); + } + + const chapter = await Chapter.create({ + subject_id, + chapter_number, + chapter_title, + chapter_description, + duration_minutes, + is_published: is_published !== undefined ? is_published : false, + display_order: display_order || 0, + }); + + // Clear cache + await cacheUtils.deletePattern('chapters:*'); + + res.status(201).json({ + success: true, + data: chapter, + message: 'Chapter created successfully', + }); + } catch (error) { + next(error); + } + } + + /** + * Update chapter + */ + async updateChapter(req, res, next) { + try { + const { id } = req.params; + const { + chapter_number, + chapter_title, + chapter_description, + duration_minutes, + is_published, + display_order, + } = req.body; + + const chapter = await Chapter.findByPk(id); + + if (!chapter) { + return res.status(404).json({ + success: false, + message: 'Chapter not found', + }); + } + + await chapter.update({ + ...(chapter_number !== undefined && { chapter_number }), + ...(chapter_title !== undefined && { chapter_title }), + ...(chapter_description !== undefined && { chapter_description }), + ...(duration_minutes !== undefined && { duration_minutes }), + ...(is_published !== undefined && { is_published }), + ...(display_order !== undefined && { display_order }), + }); + + // Clear cache + await cacheUtils.deletePattern('chapters:*'); + await cacheUtils.deletePattern(`chapter:${id}*`); + + res.json({ + success: true, + data: chapter, + message: 'Chapter updated successfully', + }); + } catch (error) { + next(error); + } + } + + /** + * Delete chapter + */ + async deleteChapter(req, res, next) { + try { + const { id } = req.params; + + const chapter = await Chapter.findByPk(id); + + if (!chapter) { + return res.status(404).json({ + success: false, + message: 'Chapter not found', + }); + } + + // Check if chapter has lessons + const lessonsCount = await Lesson.count({ where: { chapter_id: id } }); + if (lessonsCount > 0) { + return res.status(400).json({ + success: false, + message: `Cannot delete chapter. It has ${lessonsCount} lesson(s). Delete lessons first.`, + }); + } + + await chapter.destroy(); + + // Clear cache + await cacheUtils.deletePattern('chapters:*'); + await cacheUtils.deletePattern(`chapter:${id}*`); + + res.json({ + success: true, + message: 'Chapter deleted successfully', + }); + } catch (error) { + next(error); + } + } + + /** + * Get lessons in a chapter + */ + async getLessonsByChapter(req, res, next) { + try { + const { id } = req.params; + const { page = 1, limit = 20, is_published } = req.query; + const offset = (page - 1) * limit; + + const cacheKey = `chapter:${id}:lessons:${page}:${limit}:${is_published || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const chapter = await Chapter.findByPk(id); + if (!chapter) { + return res.status(404).json({ + success: false, + message: 'Chapter not found', + }); + } + + const where = { chapter_id: id }; + if (is_published !== undefined) where.is_published = is_published === 'true'; + + const { count, rows } = await Lesson.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['display_order', 'ASC'], ['lesson_number', 'ASC']], + }); + + const result = { + chapter: { + id: chapter.id, + chapter_title: chapter.chapter_title, + chapter_number: chapter.chapter_number, + }, + lessons: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + await cacheUtils.set(cacheKey, result, 1800); + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new ChapterController(); diff --git a/controllers/classController.js b/controllers/classController.js new file mode 100644 index 0000000..5837da7 --- /dev/null +++ b/controllers/classController.js @@ -0,0 +1,364 @@ +const { Class } = require('../models'); +const { StudentDetail, UserProfile, UsersAuth, School } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob } = require('../config/bullmq'); + +/** + * Class Controller - Quản lý thông tin lớp học + */ +class ClassController { + /** + * Get all classes with pagination and caching + */ + async getAllClasses(req, res, next) { + try { + const { page = 1, limit = 20, school_id, grade_level, academic_year_id } = req.query; + const offset = (page - 1) * limit; + + // Generate cache key + const cacheKey = `classes:list:${page}:${limit}:${school_id || 'all'}:${grade_level || 'all'}:${academic_year_id || 'all'}`; + + // Try to get from cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Build query conditions + const where = {}; + if (school_id) where.school_id = school_id; + if (grade_level) where.grade_level = parseInt(grade_level); + if (academic_year_id) where.academic_year_id = academic_year_id; + + // Query from database (through ProxySQL) + const { count, rows } = await Class.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['class_name', 'ASC']], + }); + + const result = { + classes: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + // Cache the result + await cacheUtils.set(cacheKey, result, 1800); // Cache for 30 minutes + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get class by ID with caching + */ + async getClassById(req, res, next) { + try { + const { id } = req.params; + const cacheKey = `class:${id}`; + + // Try cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Query from database + const classData = await Class.findByPk(id); + + if (!classData) { + return res.status(404).json({ + success: false, + message: 'Class not found', + }); + } + + // Cache the result + await cacheUtils.set(cacheKey, classData, 3600); // Cache for 1 hour + + res.json({ + success: true, + data: classData, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Create new class (async via BullMQ) + */ + async createClass(req, res, next) { + try { + const classData = req.body; + + // Add to job queue for async processing + const job = await addDatabaseWriteJob('create', 'Class', classData); + + // Invalidate related caches + await cacheUtils.deletePattern('classes:list:*'); + + res.status(202).json({ + success: true, + message: 'Class creation job queued', + jobId: job.id, + data: classData, + }); + } catch (error) { + next(error); + } + } + + /** + * Update class (async via BullMQ) + */ + async updateClass(req, res, next) { + try { + const { id } = req.params; + const updates = req.body; + + // Add to job queue for async processing + const job = await addDatabaseWriteJob('update', 'Class', { + id, + updates, + }); + + // Invalidate related caches + await cacheUtils.delete(`class:${id}`); + await cacheUtils.deletePattern('classes:list:*'); + + res.status(202).json({ + success: true, + message: 'Class update job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Delete class (async via BullMQ) + */ + async deleteClass(req, res, next) { + try { + const { id } = req.params; + + // Add to job queue for async processing + const job = await addDatabaseWriteJob('delete', 'Class', { id }); + + // Invalidate related caches + await cacheUtils.delete(`class:${id}`); + await cacheUtils.deletePattern('classes:list:*'); + + res.status(202).json({ + success: true, + message: 'Class deletion job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Get class statistics + */ + async getClassStatistics(req, res, next) { + try { + const { school_id } = req.query; + const cacheKey = `class:stats:${school_id || 'all'}`; + + // Try cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = {}; + if (school_id) where.school_id = school_id; + + const stats = { + totalClasses: await Class.count({ where }), + byGradeLevel: await Class.findAll({ + where, + attributes: [ + 'grade_level', + [require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'], + ], + group: ['grade_level'], + raw: true, + }), + }; + + // Cache for 1 hour + await cacheUtils.set(cacheKey, stats, 3600); + + res.json({ + success: true, + data: stats, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get class datatypes + */ + async getClassDatatypes(req, res, next) { + try { + const datatypes = Class.rawAttributes; + res.json({ + success: true, + data: datatypes, + }); + } catch (error) { + next(error); + } + } + + /** + * Get students in a class + */ + async getStudentsByClass(req, res, next) { + try { + const { id } = req.params; + const { page = 1, limit = 20, status = 'active' } = req.query; + const offset = (page - 1) * limit; + + // Generate cache key + const cacheKey = `class:${id}:students:${page}:${limit}:${status}`; + + // Try cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Check if class exists and get school info + const classData = await Class.findByPk(id, { + include: [ + { + model: School, + as: 'school', + attributes: ['school_name', 'school_code', 'address', 'city', 'district'], + }, + ], + }); + if (!classData) { + return res.status(404).json({ + success: false, + message: 'Class not found', + }); + } + + // Query students + const { count, rows: students } = await StudentDetail.findAndCountAll({ + where: { + current_class_id: id, + status: status, + }, + include: [ + { + model: UserProfile, + as: 'profile', + attributes: [ + 'full_name', + 'first_name', + 'last_name', + 'date_of_birth', + 'phone', + 'address', + 'city', + 'district', + ], + include: [ + { + model: UsersAuth, + as: 'auth', + attributes: ['username', 'email', 'is_active'], + }, + { + model: School, + as: 'school', + attributes: ['school_name', 'school_code'], + }, + ], + }, + ], + limit: parseInt(limit), + offset: parseInt(offset), + order: [[{ model: UserProfile, as: 'profile' }, 'full_name', 'ASC']], + }); + + const result = { + class: { + id: classData.id, + class_name: classData.class_name, + class_code: classData.class_code, + grade_level: classData.grade_level, + max_students: classData.max_students, + current_students: classData.current_students, + school: classData.school ? { + school_name: classData.school.school_name, + school_code: classData.school.school_code, + address: classData.school.address, + city: classData.school.city, + district: classData.school.district, + } : null, + }, + students: students, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + // Cache for 10 minutes + await cacheUtils.set(cacheKey, result, 600); + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new ClassController(); diff --git a/controllers/gameController.js b/controllers/gameController.js new file mode 100644 index 0000000..07ce948 --- /dev/null +++ b/controllers/gameController.js @@ -0,0 +1,374 @@ +const { Game } = require('../models'); +const { cacheUtils } = require('../config/redis'); + +/** + * Game Controller - Quản lý trò chơi giáo dục + */ +class GameController { + /** + * Get all games with pagination + */ + async getAllGames(req, res, next) { + try { + const { page = 1, limit = 20, type, is_active, is_premium, difficulty_level } = req.query; + const offset = (page - 1) * limit; + + const cacheKey = `games:list:${page}:${limit}:${type || 'all'}:${is_active || 'all'}:${is_premium || 'all'}:${difficulty_level || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = {}; + if (type) where.type = type; + if (is_active !== undefined) where.is_active = is_active === 'true'; + if (is_premium !== undefined) where.is_premium = is_premium === 'true'; + if (difficulty_level) where.difficulty_level = difficulty_level; + + const { count, rows } = await Game.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['display_order', 'ASC'], ['rating', 'DESC']], + }); + + const result = { + games: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + await cacheUtils.set(cacheKey, result, 1800); + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get game by ID + */ + async getGameById(req, res, next) { + try { + const { id } = req.params; + const cacheKey = `game:${id}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const game = await Game.findByPk(id); + + if (!game) { + return res.status(404).json({ + success: false, + message: 'Game not found', + }); + } + + await cacheUtils.set(cacheKey, game, 3600); + + res.json({ + success: true, + data: game, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Create new game + */ + async createGame(req, res, next) { + try { + const { + title, + description, + url, + thumbnail, + type, + config, + is_active, + is_premium, + min_grade, + max_grade, + difficulty_level, + display_order, + } = req.body; + + // Validate required fields + if (!title || !url || !type) { + return res.status(400).json({ + success: false, + message: 'title, url, and type are required', + }); + } + + const game = await Game.create({ + title, + description, + url, + thumbnail, + type, + config: config || {}, + is_active: is_active !== undefined ? is_active : true, + is_premium: is_premium !== undefined ? is_premium : false, + min_grade, + max_grade, + difficulty_level, + display_order: display_order || 0, + play_count: 0, + }); + + // Clear cache + await cacheUtils.deletePattern('games:*'); + + res.status(201).json({ + success: true, + data: game, + message: 'Game created successfully', + }); + } catch (error) { + next(error); + } + } + + /** + * Update game + */ + async updateGame(req, res, next) { + try { + const { id } = req.params; + const { + title, + description, + url, + thumbnail, + type, + config, + is_active, + is_premium, + min_grade, + max_grade, + difficulty_level, + display_order, + rating, + } = req.body; + + const game = await Game.findByPk(id); + + if (!game) { + return res.status(404).json({ + success: false, + message: 'Game not found', + }); + } + + await game.update({ + ...(title !== undefined && { title }), + ...(description !== undefined && { description }), + ...(url !== undefined && { url }), + ...(thumbnail !== undefined && { thumbnail }), + ...(type !== undefined && { type }), + ...(config !== undefined && { config }), + ...(is_active !== undefined && { is_active }), + ...(is_premium !== undefined && { is_premium }), + ...(min_grade !== undefined && { min_grade }), + ...(max_grade !== undefined && { max_grade }), + ...(difficulty_level !== undefined && { difficulty_level }), + ...(display_order !== undefined && { display_order }), + ...(rating !== undefined && { rating }), + }); + + // Clear cache + await cacheUtils.deletePattern('games:*'); + await cacheUtils.deletePattern(`game:${id}`); + + res.json({ + success: true, + data: game, + message: 'Game updated successfully', + }); + } catch (error) { + next(error); + } + } + + /** + * Delete game + */ + async deleteGame(req, res, next) { + try { + const { id } = req.params; + + const game = await Game.findByPk(id); + + if (!game) { + return res.status(404).json({ + success: false, + message: 'Game not found', + }); + } + + await game.destroy(); + + // Clear cache + await cacheUtils.deletePattern('games:*'); + await cacheUtils.deletePattern(`game:${id}`); + + res.json({ + success: true, + message: 'Game deleted successfully', + }); + } catch (error) { + next(error); + } + } + + /** + * Increment play count + */ + async incrementPlayCount(req, res, next) { + try { + const { id } = req.params; + + const game = await Game.findByPk(id); + + if (!game) { + return res.status(404).json({ + success: false, + message: 'Game not found', + }); + } + + await game.increment('play_count'); + await game.reload(); + + // Clear cache + await cacheUtils.deletePattern(`game:${id}`); + + res.json({ + success: true, + data: { + id: game.id, + play_count: game.play_count, + }, + message: 'Play count incremented', + }); + } catch (error) { + next(error); + } + } + + /** + * Get games by type (for lesson matching) + */ + async getGamesByType(req, res, next) { + try { + const { type } = req.params; + const { only_active = 'true' } = req.query; + + const cacheKey = `games:type:${type}:${only_active}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = { type }; + if (only_active === 'true') where.is_active = true; + + const games = await Game.findAll({ + where, + order: [['display_order', 'ASC'], ['rating', 'DESC']], + }); + + await cacheUtils.set(cacheKey, games, 3600); + + res.json({ + success: true, + data: games, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get game statistics + */ + async getGameStats(req, res, next) { + try { + const cacheKey = 'games:stats'; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const [totalGames, activeGames, premiumGames, totalPlays] = await Promise.all([ + Game.count(), + Game.count({ where: { is_active: true } }), + Game.count({ where: { is_premium: true } }), + Game.sum('play_count'), + ]); + + const topGames = await Game.findAll({ + where: { is_active: true }, + order: [['play_count', 'DESC']], + limit: 10, + attributes: ['id', 'title', 'type', 'play_count', 'rating'], + }); + + const stats = { + totalGames, + activeGames, + premiumGames, + totalPlays: totalPlays || 0, + topGames, + }; + + await cacheUtils.set(cacheKey, stats, 600); + + res.json({ + success: true, + data: stats, + cached: false, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new GameController(); diff --git a/controllers/gradeController.js b/controllers/gradeController.js new file mode 100644 index 0000000..ca41300 --- /dev/null +++ b/controllers/gradeController.js @@ -0,0 +1,264 @@ +const { Grade, GradeItem, GradeSummary, StudentDetail, Subject, AcademicYear } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob, addGradeCalculationJob } = require('../config/bullmq'); + +/** + * Grade Controller - Quản lý điểm số + */ +class GradeController { + /** + * Get grades with pagination + */ + async getAllGrades(req, res, next) { + try { + const { page = 1, limit = 50, student_id, subject_id, academic_year_id } = req.query; + const offset = (page - 1) * limit; + + const cacheKey = `grades:list:${page}:${limit}:${student_id || 'all'}:${subject_id || 'all'}:${academic_year_id || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = {}; + if (student_id) where.student_id = student_id; + if (subject_id) where.subject_id = subject_id; + if (academic_year_id) where.academic_year_id = academic_year_id; + + const { count, rows } = await Grade.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + include: [ + { + model: StudentDetail, + as: 'student', + attributes: ['student_code'], + }, + { + model: GradeItem, + as: 'gradeItem', + attributes: ['item_name', 'max_score'], + }, + ], + order: [['created_at', 'DESC']], + }); + + const result = { + grades: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + await cacheUtils.set(cacheKey, result, 1800); + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get grade by ID + */ + async getGradeById(req, res, next) { + try { + const { id } = req.params; + const cacheKey = `grade:${id}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const grade = await Grade.findByPk(id, { + include: [ + { + model: StudentDetail, + as: 'student', + }, + { + model: GradeItem, + as: 'gradeItem', + }, + ], + }); + + if (!grade) { + return res.status(404).json({ + success: false, + message: 'Grade not found', + }); + } + + await cacheUtils.set(cacheKey, grade, 3600); + + res.json({ + success: true, + data: grade, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Create new grade (async via BullMQ) + */ + async createGrade(req, res, next) { + try { + const gradeData = req.body; + + const job = await addDatabaseWriteJob('create', 'Grade', gradeData); + + await cacheUtils.deletePattern('grades:list:*'); + await cacheUtils.deletePattern(`grade:student:${gradeData.student_id}:*`); + + res.status(202).json({ + success: true, + message: 'Grade creation job queued', + jobId: job.id, + data: gradeData, + }); + } catch (error) { + next(error); + } + } + + /** + * Update grade (async via BullMQ) + */ + async updateGrade(req, res, next) { + try { + const { id } = req.params; + const updates = req.body; + + const job = await addDatabaseWriteJob('update', 'Grade', { + id, + updates, + }); + + await cacheUtils.delete(`grade:${id}`); + await cacheUtils.deletePattern('grades:list:*'); + + res.status(202).json({ + success: true, + message: 'Grade update job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Delete grade (async via BullMQ) + */ + async deleteGrade(req, res, next) { + try { + const { id } = req.params; + + const job = await addDatabaseWriteJob('delete', 'Grade', { id }); + + await cacheUtils.delete(`grade:${id}`); + await cacheUtils.deletePattern('grades:list:*'); + + res.status(202).json({ + success: true, + message: 'Grade deletion job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Get student grade summary + */ + async getStudentGradeSummary(req, res, next) { + try { + const { student_id, academic_year_id } = req.params; + const cacheKey = `grade:student:${student_id}:${academic_year_id}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const summary = await GradeSummary.findOne({ + where: { student_id, academic_year_id }, + }); + + await cacheUtils.set(cacheKey, summary, 3600); + + res.json({ + success: true, + data: summary, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Calculate student GPA (async via BullMQ) + */ + async calculateGPA(req, res, next) { + try { + const { student_id, academic_year_id } = req.body; + + const job = await addGradeCalculationJob(student_id, academic_year_id); + + await cacheUtils.deletePattern(`grade:student:${student_id}:*`); + + res.status(202).json({ + success: true, + message: 'GPA calculation job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Get grade datatypes + */ + async getGradeDatatypes(req, res, next) { + try { + const datatypes = Grade.rawAttributes; + res.json({ + success: true, + data: datatypes, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new GradeController(); diff --git a/controllers/parentTaskController.js b/controllers/parentTaskController.js new file mode 100644 index 0000000..0308d63 --- /dev/null +++ b/controllers/parentTaskController.js @@ -0,0 +1,391 @@ +const { ParentAssignedTask, GradeItem, StudentDetail, UsersAuth, ParentStudentMap } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob, addNotificationJob } = require('../config/bullmq'); +const { v4: uuidv4 } = require('uuid'); + +/** + * Parent Task Controller - Quản lý bài tập phụ huynh giao cho con + */ +class ParentTaskController { + /** + * POST /api/parent-tasks/assign - Phụ huynh giao bài tập cho con + */ + async assignTask(req, res, next) { + try { + const { parent_id, student_id, grade_item_id, due_date, message, priority } = req.body; + + if (!parent_id || !student_id || !grade_item_id || !due_date) { + return res.status(400).json({ + success: false, + message: 'Missing required fields: parent_id, student_id, grade_item_id, due_date', + }); + } + + // Kiểm tra quyền của phụ huynh + const parentMapping = await ParentStudentMap.findOne({ + where: { + parent_id, + student_id, + }, + }); + + if (!parentMapping) { + return res.status(403).json({ + success: false, + message: 'Parent-student relationship not found', + }); + } + + if (!parentMapping.can_assign_tasks) { + return res.status(403).json({ + success: false, + message: 'Parent does not have permission to assign tasks', + }); + } + + // Kiểm tra grade_item có tồn tại không + const gradeItem = await GradeItem.findByPk(grade_item_id); + if (!gradeItem) { + return res.status(404).json({ + success: false, + message: 'Grade item not found', + }); + } + + const taskData = { + id: uuidv4(), + parent_id, + student_id, + grade_item_id, + assigned_at: new Date(), + due_date: new Date(due_date), + status: 'pending', + message: message || null, + priority: priority || 'normal', + }; + + // Async write to DB via BullMQ + await addDatabaseWriteJob('create', 'ParentAssignedTask', taskData); + + // Gửi notification cho học sinh + await addNotificationJob({ + user_id: student_id, + type: 'parent_task_assigned', + title: 'Bài tập mới từ phụ huynh', + message: message || `Bạn có bài tập mới: ${gradeItem.item_name}`, + data: taskData, + }); + + // Clear cache + await cacheUtils.del(`parent:tasks:${parent_id}`); + await cacheUtils.del(`student:tasks:${student_id}`); + + res.status(202).json({ + success: true, + message: 'Task assignment request queued', + data: taskData, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/parent-tasks/parent/:parent_id - Danh sách bài tập phụ huynh đã giao + */ + async getParentTasks(req, res, next) { + try { + const { parent_id } = req.params; + const { status, student_id } = req.query; + + const cacheKey = `parent:tasks:${parent_id}:${status || 'all'}:${student_id || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = { parent_id }; + if (status) where.status = status; + if (student_id) where.student_id = student_id; + + const tasks = await ParentAssignedTask.findAll({ + where, + include: [ + { + model: StudentDetail, + as: 'student', + attributes: ['student_code', 'full_name'], + }, + { + model: GradeItem, + as: 'gradeItem', + attributes: ['item_name', 'max_score', 'description'], + }, + ], + order: [ + ['priority', 'DESC'], + ['due_date', 'ASC'], + ], + }); + + // Check for overdue tasks + const now = new Date(); + for (const task of tasks) { + if (task.status !== 'completed' && new Date(task.due_date) < now) { + if (task.status !== 'overdue') { + await addDatabaseWriteJob('update', 'ParentAssignedTask', { + id: task.id, + status: 'overdue', + }); + task.status = 'overdue'; + } + } + } + + await cacheUtils.set(cacheKey, tasks, 900); // Cache 15 min + + res.json({ + success: true, + data: tasks, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/parent-tasks/student/:student_id - Danh sách bài tập của học sinh + */ + async getStudentTasks(req, res, next) { + try { + const { student_id } = req.params; + const { status } = req.query; + + const cacheKey = `student:tasks:${student_id}:${status || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = { student_id }; + if (status) where.status = status; + + const tasks = await ParentAssignedTask.findAll({ + where, + include: [ + { + model: UsersAuth, + as: 'parent', + attributes: ['username', 'full_name'], + }, + { + model: GradeItem, + as: 'gradeItem', + attributes: ['item_name', 'max_score', 'description'], + }, + ], + order: [ + ['priority', 'DESC'], + ['due_date', 'ASC'], + ], + }); + + await cacheUtils.set(cacheKey, tasks, 900); // Cache 15 min + + res.json({ + success: true, + data: tasks, + }); + } catch (error) { + next(error); + } + } + + /** + * PUT /api/parent-tasks/:id/complete - Học sinh hoàn thành bài tập + */ + async completeTask(req, res, next) { + try { + const { id } = req.params; + const { student_notes } = req.body; + + const task = await ParentAssignedTask.findByPk(id); + + if (!task) { + return res.status(404).json({ + success: false, + message: 'Task not found', + }); + } + + if (task.status === 'completed') { + return res.status(400).json({ + success: false, + message: 'Task already completed', + }); + } + + await addDatabaseWriteJob('update', 'ParentAssignedTask', { + id, + status: 'completed', + completion_date: new Date(), + student_notes: student_notes || null, + }); + + // Gửi notification cho phụ huynh + await addNotificationJob({ + user_id: task.parent_id, + type: 'parent_task_completed', + title: 'Bài tập đã hoàn thành', + message: `Con bạn đã hoàn thành bài tập`, + data: { task_id: id }, + }); + + // Clear cache + await cacheUtils.del(`parent:tasks:${task.parent_id}:*`); + await cacheUtils.del(`student:tasks:${task.student_id}:*`); + + res.status(202).json({ + success: true, + message: 'Task completion request queued', + }); + } catch (error) { + next(error); + } + } + + /** + * PUT /api/parent-tasks/:id/status - Cập nhật trạng thái bài tập + */ + async updateTaskStatus(req, res, next) { + try { + const { id } = req.params; + const { status } = req.body; + + const validStatuses = ['pending', 'in_progress', 'completed', 'overdue']; + if (!validStatuses.includes(status)) { + return res.status(400).json({ + success: false, + message: `Invalid status. Must be one of: ${validStatuses.join(', ')}`, + }); + } + + const task = await ParentAssignedTask.findByPk(id); + if (!task) { + return res.status(404).json({ + success: false, + message: 'Task not found', + }); + } + + await addDatabaseWriteJob('update', 'ParentAssignedTask', { + id, + status, + }); + + // Clear cache + await cacheUtils.del(`parent:tasks:${task.parent_id}:*`); + await cacheUtils.del(`student:tasks:${task.student_id}:*`); + + res.status(202).json({ + success: true, + message: 'Task status update queued', + }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /api/parent-tasks/:id - Xóa bài tập (chỉ phụ huynh mới xóa được) + */ + async deleteTask(req, res, next) { + try { + const { id } = req.params; + const { parent_id } = req.body; // Xác thực parent_id + + const task = await ParentAssignedTask.findByPk(id); + + if (!task) { + return res.status(404).json({ + success: false, + message: 'Task not found', + }); + } + + if (task.parent_id !== parent_id) { + return res.status(403).json({ + success: false, + message: 'Only the parent who assigned the task can delete it', + }); + } + + await addDatabaseWriteJob('delete', 'ParentAssignedTask', { id }); + + // Clear cache + await cacheUtils.del(`parent:tasks:${task.parent_id}:*`); + await cacheUtils.del(`student:tasks:${task.student_id}:*`); + + res.status(202).json({ + success: true, + message: 'Task deletion request queued', + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/parent-tasks/stats - Thống kê bài tập + */ + async getTaskStats(req, res, next) { + try { + const { parent_id, student_id } = req.query; + + const cacheKey = `parent:tasks:stats:${parent_id || 'all'}:${student_id || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = {}; + if (parent_id) where.parent_id = parent_id; + if (student_id) where.student_id = student_id; + + const stats = await ParentAssignedTask.findAll({ + where, + attributes: [ + 'status', + [require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'], + ], + group: ['status'], + }); + + await cacheUtils.set(cacheKey, stats, 300); // Cache 5 min + + res.json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new ParentTaskController(); diff --git a/controllers/roomController.js b/controllers/roomController.js new file mode 100644 index 0000000..4f06ee9 --- /dev/null +++ b/controllers/roomController.js @@ -0,0 +1,219 @@ +const { Room, School } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob } = require('../config/bullmq'); + +/** + * Room Controller - Quản lý phòng học + */ +class RoomController { + /** + * Get all rooms with pagination and caching + */ + async getAllRooms(req, res, next) { + try { + const { page = 1, limit = 50, school_id, room_type } = req.query; + const offset = (page - 1) * limit; + + const cacheKey = `rooms:list:${page}:${limit}:${school_id || 'all'}:${room_type || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = {}; + if (school_id) where.school_id = school_id; + if (room_type) where.room_type = room_type; + + const { count, rows } = await Room.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['school_id', 'ASC'], ['floor', 'ASC'], ['room_code', 'ASC']], + }); + + const result = { + rooms: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + await cacheUtils.set(cacheKey, result, 3600); + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get room by ID + */ + async getRoomById(req, res, next) { + try { + const { id } = req.params; + const cacheKey = `room:${id}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const room = await Room.findByPk(id); + + if (!room) { + return res.status(404).json({ + success: false, + message: 'Room not found', + }); + } + + await cacheUtils.set(cacheKey, room, 7200); + + res.json({ + success: true, + data: room, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Create new room (async via BullMQ) + */ + async createRoom(req, res, next) { + try { + const roomData = req.body; + + const job = await addDatabaseWriteJob('create', 'Room', roomData); + + await cacheUtils.deletePattern('rooms:list:*'); + + res.status(202).json({ + success: true, + message: 'Room creation job queued', + jobId: job.id, + data: roomData, + }); + } catch (error) { + next(error); + } + } + + /** + * Update room (async via BullMQ) + */ + async updateRoom(req, res, next) { + try { + const { id } = req.params; + const updates = req.body; + + const job = await addDatabaseWriteJob('update', 'Room', { + id, + updates, + }); + + await cacheUtils.delete(`room:${id}`); + await cacheUtils.deletePattern('rooms:list:*'); + + res.status(202).json({ + success: true, + message: 'Room update job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Delete room (async via BullMQ) + */ + async deleteRoom(req, res, next) { + try { + const { id } = req.params; + + const job = await addDatabaseWriteJob('delete', 'Room', { id }); + + await cacheUtils.delete(`room:${id}`); + await cacheUtils.deletePattern('rooms:list:*'); + + res.status(202).json({ + success: true, + message: 'Room deletion job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Get rooms by school + */ + async getRoomsBySchool(req, res, next) { + try { + const { school_id } = req.params; + const cacheKey = `rooms:school:${school_id}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const rooms = await Room.findAll({ + where: { school_id }, + order: [['floor', 'ASC'], ['room_code', 'ASC']], + }); + + await cacheUtils.set(cacheKey, rooms, 7200); + + res.json({ + success: true, + data: rooms, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get room datatypes + */ + async getRoomDatatypes(req, res, next) { + try { + const datatypes = Room.rawAttributes; + res.json({ + success: true, + data: datatypes, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new RoomController(); diff --git a/controllers/schoolController.js b/controllers/schoolController.js new file mode 100644 index 0000000..654cc2b --- /dev/null +++ b/controllers/schoolController.js @@ -0,0 +1,302 @@ +const { School } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob } = require('../config/bullmq'); + +/** + * School Controller - Quản lý thông tin trường học + */ +class SchoolController { + /** + * Get all schools with pagination and caching + */ + async getAllSchools(req, res, next) { + try { + const { page = 1, limit = 20, type, city, is_active } = req.query; + const offset = (page - 1) * limit; + + // Generate cache key + const cacheKey = `schools:list:${page}:${limit}:${type || 'all'}:${city || 'all'}:${is_active || 'all'}`; + + // Try to get from cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Build query conditions + const where = {}; + if (type) where.school_type = type; + if (city) where.city = city; + if (is_active !== undefined) { + where.is_active = is_active === 'true' || is_active === true; + } + + // Query from database (through ProxySQL) + const { count, rows } = await School.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['school_name', 'ASC']], + attributes: { exclude: ['config'] }, // Exclude sensitive data + }); + + const result = { + schools: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + // Cache the result + await cacheUtils.set(cacheKey, result, 3600); // Cache for 1 hour + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + /** + * Get school by ID with caching + */ + async getSchoolById(req, res, next) { + try { + const { id } = req.params; + const cacheKey = `school:${id}`; + + // Try cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Query from database + const school = await School.findByPk(id); + + if (!school) { + return res.status(404).json({ + success: false, + message: 'School not found', + }); + } + + // Cache the result + await cacheUtils.set(cacheKey, school, 7200); // Cache for 2 hours + + res.json({ + success: true, + data: school, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Create new school - Push to BullMQ + */ + async createSchool(req, res, next) { + try { + const schoolData = req.body; + const userId = req.user?.id; // From auth middleware + + // Validate required fields (you can use Joi for this) + if (!schoolData.school_code || !schoolData.school_name || !schoolData.school_type) { + return res.status(400).json({ + success: false, + message: 'Missing required fields', + }); + } + + // Add job to BullMQ queue + const job = await addDatabaseWriteJob( + 'create', + 'School', + schoolData, + { userId, priority: 3 } + ); + + res.status(202).json({ + success: true, + message: 'School creation queued', + jobId: job.id, + data: { + school_code: schoolData.school_code, + school_name: schoolData.school_name, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * Update school - Push to BullMQ + */ + async updateSchool(req, res, next) { + try { + const { id } = req.params; + const updateData = req.body; + const userId = req.user?.id; + + // Check if school exists (from cache or DB) + const cacheKey = `school:${id}`; + let school = await cacheUtils.get(cacheKey); + + if (!school) { + school = await School.findByPk(id); + if (!school) { + return res.status(404).json({ + success: false, + message: 'School not found', + }); + } + } + + // Add update job to BullMQ + const job = await addDatabaseWriteJob( + 'update', + 'School', + { id, ...updateData }, + { userId, priority: 3 } + ); + + // Invalidate cache + await cacheUtils.delete(cacheKey); + await cacheUtils.deletePattern('schools:list:*'); + + res.json({ + success: true, + message: 'School update queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Delete school - Push to BullMQ + */ + async deleteSchool(req, res, next) { + try { + const { id } = req.params; + const userId = req.user?.id; + + // Check if school exists + const school = await School.findByPk(id); + if (!school) { + return res.status(404).json({ + success: false, + message: 'School not found', + }); + } + + // Soft delete - just mark as inactive + const job = await addDatabaseWriteJob( + 'update', + 'School', + { id, is_active: false }, + { userId, priority: 3 } + ); + + // Invalidate cache + await cacheUtils.delete(`school:${id}`); + await cacheUtils.deletePattern('schools:list:*'); + + res.json({ + success: true, + message: 'School deletion queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Get school statistics + */ + async getSchoolStats(req, res, next) { + try { + const { id } = req.params; + const cacheKey = `school:${id}:stats`; + + // Try cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Get school with related data + const school = await School.findByPk(id, { + include: [ + { association: 'classes', attributes: ['id'] }, + { association: 'rooms', attributes: ['id'] }, + ], + }); + + if (!school) { + return res.status(404).json({ + success: false, + message: 'School not found', + }); + } + + const stats = { + school_id: id, + school_name: school.school_name, + total_classes: school.classes?.length || 0, + total_rooms: school.rooms?.length || 0, + capacity: school.capacity, + }; + + // Cache for 30 minutes + await cacheUtils.set(cacheKey, stats, 1800); + + res.json({ + success: true, + data: stats, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get school datatypes + */ + async getSchoolDatatypes(req, res, next) { + try { + const datatypes = School.rawAttributes; + res.json({ + success: true, + data: datatypes, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new SchoolController(); diff --git a/controllers/studentController.js b/controllers/studentController.js new file mode 100644 index 0000000..3d42652 --- /dev/null +++ b/controllers/studentController.js @@ -0,0 +1,211 @@ +const { StudentDetail, UserProfile, UsersAuth, Class } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob } = require('../config/bullmq'); + +/** + * Student Controller - Quản lý học sinh + */ +class StudentController { + /** + * Get all students with pagination and caching + */ + async getAllStudents(req, res, next) { + try { + const { page = 1, limit = 20, status, class_id } = req.query; + const offset = (page - 1) * limit; + + const cacheKey = `students:list:${page}:${limit}:${status || 'all'}:${class_id || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = {}; + if (status) where.status = status; + if (class_id) where.current_class_id = class_id; + + const { count, rows } = await StudentDetail.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + include: [ + { + model: UserProfile, + as: 'profile', + attributes: ['full_name', 'phone', 'date_of_birth', 'gender'], + }, + { + model: Class, + as: 'currentClass', + attributes: ['class_code', 'class_name', 'grade_level'], + }, + ], + order: [['created_at', 'DESC']], + }); + + const result = { + students: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + await cacheUtils.set(cacheKey, result, 1800); + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get student by ID + */ + async getStudentById(req, res, next) { + try { + const { id } = req.params; + const cacheKey = `student:${id}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const student = await StudentDetail.findByPk(id, { + include: [ + { + model: UserProfile, + as: 'profile', + }, + { + model: Class, + as: 'currentClass', + }, + ], + }); + + if (!student) { + return res.status(404).json({ + success: false, + message: 'Student not found', + }); + } + + await cacheUtils.set(cacheKey, student, 3600); + + res.json({ + success: true, + data: student, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Create new student (async via BullMQ) + */ + async createStudent(req, res, next) { + try { + const studentData = req.body; + + const job = await addDatabaseWriteJob('create', 'StudentDetail', studentData); + + await cacheUtils.deletePattern('students:list:*'); + + res.status(202).json({ + success: true, + message: 'Student creation job queued', + jobId: job.id, + data: studentData, + }); + } catch (error) { + next(error); + } + } + + /** + * Update student (async via BullMQ) + */ + async updateStudent(req, res, next) { + try { + const { id } = req.params; + const updates = req.body; + + const job = await addDatabaseWriteJob('update', 'StudentDetail', { + id, + updates, + }); + + await cacheUtils.delete(`student:${id}`); + await cacheUtils.deletePattern('students:list:*'); + + res.status(202).json({ + success: true, + message: 'Student update job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Delete student (update status to dropped) + */ + async deleteStudent(req, res, next) { + try { + const { id } = req.params; + + const job = await addDatabaseWriteJob('update', 'StudentDetail', { + id, + updates: { status: 'dropped' }, + }); + + await cacheUtils.delete(`student:${id}`); + await cacheUtils.deletePattern('students:list:*'); + + res.status(202).json({ + success: true, + message: 'Student deletion job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Get student datatypes + */ + async getStudentDatatypes(req, res, next) { + try { + const datatypes = StudentDetail.rawAttributes; + res.json({ + success: true, + data: datatypes, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new StudentController(); diff --git a/controllers/subjectController.js b/controllers/subjectController.js new file mode 100644 index 0000000..6ac13b7 --- /dev/null +++ b/controllers/subjectController.js @@ -0,0 +1,343 @@ +const { Subject, Chapter } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob } = require('../config/bullmq'); + +/** + * Subject Controller - Quản lý môn học + */ +class SubjectController { + /** + * Get all subjects with pagination and caching + */ + async getAllSubjects(req, res, next) { + try { + const { page = 1, limit = 50, is_active } = req.query; + const offset = (page - 1) * limit; + + // Generate cache key + const cacheKey = `subjects:list:${page}:${limit}:${is_active || 'all'}`; + + // Try to get from cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Build query conditions + const where = {}; + if (is_active !== undefined) where.is_active = is_active === 'true'; + + // Query from database (through ProxySQL) + const { count, rows } = await Subject.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['subject_code', 'ASC']], + }); + + const result = { + subjects: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + // Cache the result + await cacheUtils.set(cacheKey, result, 7200); // Cache for 2 hours + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get subject by ID with caching + */ + async getSubjectById(req, res, next) { + try { + const { id } = req.params; + const cacheKey = `subject:${id}`; + + // Try cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Query from database + const subject = await Subject.findByPk(id); + + if (!subject) { + return res.status(404).json({ + success: false, + message: 'Subject not found', + }); + } + + // Cache the result + await cacheUtils.set(cacheKey, subject, 7200); // Cache for 2 hours + + res.json({ + success: true, + data: subject, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get subject by code with caching + */ + async getSubjectByCode(req, res, next) { + try { + const { code } = req.params; + const cacheKey = `subject:code:${code}`; + + // Try cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Query from database + const subject = await Subject.findOne({ + where: { subject_code: code }, + }); + + if (!subject) { + return res.status(404).json({ + success: false, + message: 'Subject not found', + }); + } + + // Cache the result + await cacheUtils.set(cacheKey, subject, 7200); // Cache for 2 hours + + res.json({ + success: true, + data: subject, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Create new subject (async via BullMQ) + */ + async createSubject(req, res, next) { + try { + const subjectData = req.body; + + // Add to job queue for async processing + const job = await addDatabaseWriteJob('create', 'Subject', subjectData); + + // Note: Cache will be invalidated by worker after successful creation + + res.status(202).json({ + success: true, + message: 'Subject creation job queued', + jobId: job.id, + data: subjectData, + }); + } catch (error) { + next(error); + } + } + + /** + * Update subject (async via BullMQ) + */ + async updateSubject(req, res, next) { + try { + const { id } = req.params; + const updates = req.body; + + // Add to job queue for async processing + const job = await addDatabaseWriteJob('update', 'Subject', { + id, + updates, + }); + + // Note: Cache will be invalidated by worker after successful update + + res.status(202).json({ + success: true, + message: 'Subject update job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Delete subject (async via BullMQ) + */ + async deleteSubject(req, res, next) { + try { + const { id } = req.params; + + // Add to job queue for async processing + const job = await addDatabaseWriteJob('delete', 'Subject', { id }); + + // Note: Cache will be invalidated by worker after successful deletion + + res.status(202).json({ + success: true, + message: 'Subject deletion job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Get active subjects (frequently used) + */ + async getActiveSubjects(req, res, next) { + try { + const cacheKey = 'subjects:active'; + + // Try cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Query from database + const subjects = await Subject.findAll({ + where: { is_active: true }, + order: [['subject_code', 'ASC']], + }); + + // Cache for 4 hours (this data changes infrequently) + await cacheUtils.set(cacheKey, subjects, 14400); + + res.json({ + success: true, + data: subjects, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get subject datatypes + */ + async getSubjectDatatypes(req, res, next) { + try { + const datatypes = Subject.rawAttributes; + res.json({ + success: true, + data: datatypes, + }); + } catch (error) { + next(error); + } + } + + /** + * Get chapters by subject ID + */ + async getChaptersBySubject(req, res, next) { + try { + const { id } = req.params; + const { page = 1, limit = 50, is_published } = req.query; + const offset = (page - 1) * limit; + + // Generate cache key + const cacheKey = `subject:${id}:chapters:${page}:${limit}:${is_published || 'all'}`; + + // Try cache first + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + // Check if subject exists + const subject = await Subject.findByPk(id); + if (!subject) { + return res.status(404).json({ + success: false, + message: 'Subject not found', + }); + } + + // Build query conditions + const where = { subject_id: id }; + if (is_published !== undefined) where.is_published = is_published === 'true'; + + // Query chapters + const { count, rows } = await Chapter.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['display_order', 'ASC'], ['chapter_number', 'ASC']], + }); + + const result = { + subject: { + id: subject.id, + subject_code: subject.subject_code, + subject_name: subject.subject_name, + }, + chapters: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + // Cache the result + await cacheUtils.set(cacheKey, result, 3600); // Cache for 1 hour + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new SubjectController(); diff --git a/controllers/subscriptionController.js b/controllers/subscriptionController.js new file mode 100644 index 0000000..c285341 --- /dev/null +++ b/controllers/subscriptionController.js @@ -0,0 +1,253 @@ +const { SubscriptionPlan, UserSubscription, UsersAuth } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob } = require('../config/bullmq'); +const { v4: uuidv4 } = require('uuid'); + +/** + * Subscription Controller - Quản lý gói thẻ tháng + */ +class SubscriptionController { + /** + * GET /api/subscriptions/plans - Danh sách các gói subscription + */ + async getPlans(req, res, next) { + try { + const { target_role } = req.query; + const cacheKey = `subscription:plans:${target_role || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = { is_active: true }; + if (target_role) where.target_role = target_role; + + const plans = await SubscriptionPlan.findAll({ + where, + order: [['price', 'ASC']], + }); + + await cacheUtils.set(cacheKey, plans, 1800); // Cache 30 min + + res.json({ + success: true, + data: plans, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /api/subscriptions/purchase - Mua gói subscription + */ + async purchaseSubscription(req, res, next) { + try { + const { user_id, plan_id, payment_method, transaction_id } = req.body; + + if (!user_id || !plan_id || !payment_method || !transaction_id) { + return res.status(400).json({ + success: false, + message: 'Missing required fields: user_id, plan_id, payment_method, transaction_id', + }); + } + + // Kiểm tra plan có tồn tại không + const plan = await SubscriptionPlan.findByPk(plan_id); + if (!plan || !plan.is_active) { + return res.status(404).json({ + success: false, + message: 'Subscription plan not found or inactive', + }); + } + + // Kiểm tra user có subscription đang active không + const existingSubscription = await UserSubscription.findOne({ + where: { + user_id, + status: 'active', + }, + }); + + if (existingSubscription) { + return res.status(400).json({ + success: false, + message: 'User already has an active subscription', + }); + } + + const start_date = new Date(); + const end_date = new Date(start_date); + end_date.setDate(end_date.getDate() + plan.duration_days); + + const subscriptionData = { + id: uuidv4(), + user_id, + plan_id, + start_date, + end_date, + status: 'active', + transaction_id, + payment_method, + payment_amount: plan.price, + auto_renew: req.body.auto_renew || false, + }; + + // Async write to DB via BullMQ + await addDatabaseWriteJob('create', 'UserSubscription', subscriptionData); + + // Clear cache + await cacheUtils.del(`subscription:user:${user_id}`); + + res.status(202).json({ + success: true, + message: 'Subscription purchase request queued', + data: subscriptionData, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/subscriptions/user/:user_id - Kiểm tra subscription status của user + */ + async getUserSubscription(req, res, next) { + try { + const { user_id } = req.params; + const cacheKey = `subscription:user:${user_id}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const subscription = await UserSubscription.findOne({ + where: { user_id }, + include: [ + { + model: SubscriptionPlan, + as: 'plan', + attributes: ['name', 'price', 'features', 'duration_days'], + }, + ], + order: [['created_at', 'DESC']], + }); + + if (!subscription) { + return res.status(404).json({ + success: false, + message: 'No subscription found for this user', + }); + } + + // Check nếu subscription đã hết hạn + const now = new Date(); + if (subscription.status === 'active' && new Date(subscription.end_date) < now) { + subscription.status = 'expired'; + await addDatabaseWriteJob('update', 'UserSubscription', { + id: subscription.id, + status: 'expired', + }); + } + + await cacheUtils.set(cacheKey, subscription, 900); // Cache 15 min + + res.json({ + success: true, + data: subscription, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /api/subscriptions/:id/cancel - Hủy subscription + */ + async cancelSubscription(req, res, next) { + try { + const { id } = req.params; + const { reason } = req.body; + + const subscription = await UserSubscription.findByPk(id); + + if (!subscription) { + return res.status(404).json({ + success: false, + message: 'Subscription not found', + }); + } + + if (subscription.status === 'cancelled') { + return res.status(400).json({ + success: false, + message: 'Subscription already cancelled', + }); + } + + // Async update via BullMQ + await addDatabaseWriteJob('update', 'UserSubscription', { + id, + status: 'cancelled', + auto_renew: false, + }); + + // Clear cache + await cacheUtils.del(`subscription:user:${subscription.user_id}`); + + res.status(202).json({ + success: true, + message: 'Subscription cancellation request queued', + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/subscriptions/stats - Thống kê subscription + */ + async getSubscriptionStats(req, res, next) { + try { + const cacheKey = 'subscription:stats'; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const stats = await UserSubscription.findAll({ + attributes: [ + 'status', + [require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'], + ], + group: ['status'], + }); + + await cacheUtils.set(cacheKey, stats, 300); // Cache 5 min + + res.json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new SubscriptionController(); diff --git a/controllers/teacherController.js b/controllers/teacherController.js new file mode 100644 index 0000000..4d81a83 --- /dev/null +++ b/controllers/teacherController.js @@ -0,0 +1,274 @@ +const { TeacherDetail, UserProfile, UsersAuth } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob } = require('../config/bullmq'); +const teacherProfileService = require('../services/teacherProfileService'); + +/** + * Teacher Controller - Quản lý giáo viên + */ +class TeacherController { + /** + * Get all teachers with pagination and caching + */ + async getAllTeachers(req, res, next) { + try { + const { page = 1, limit = 20, status, teacher_type, school_id } = req.query; + const offset = (page - 1) * limit; + + const cacheKey = `teachers:list:${page}:${limit}:${status || 'all'}:${teacher_type || 'all'}:${school_id || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = {}; + if (status) where.status = status; + if (teacher_type) where.teacher_type = teacher_type; + + // Query teachers + const { count, rows } = await TeacherDetail.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['created_at', 'DESC']], + }); + + // Manually fetch profiles for each teacher + const teachersWithProfiles = await Promise.all( + rows.map(async (teacher) => { + const profile = await UserProfile.findOne({ + where: { user_id: teacher.user_id }, + attributes: ['id', 'full_name', 'phone', 'date_of_birth', 'gender', 'address', 'school_id', 'city', 'district', 'avatar_url'], + }); + + // Filter by school_id if provided + if (school_id && (!profile || profile.school_id !== school_id)) { + return null; + } + + return { + ...teacher.toJSON(), + profile: profile ? profile.toJSON() : null, + }; + }) + ); + + // Filter out nulls (teachers not matching school_id) + const filteredTeachers = teachersWithProfiles.filter(t => t !== null); + + const result = { + teachers: filteredTeachers, + pagination: { + total: school_id ? filteredTeachers.length : count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil((school_id ? filteredTeachers.length : count) / limit), + }, + }; + + await cacheUtils.set(cacheKey, result, 1800); + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get teacher by ID with manual profile fetch + */ + async getTeacherById(req, res, next) { + try { + const { id } = req.params; + const cacheKey = `teacher:${id}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const teacher = await TeacherDetail.findByPk(id); + + if (!teacher) { + return res.status(404).json({ + success: false, + message: 'Teacher not found', + }); + } + + // Manually fetch profile + const profile = await UserProfile.findOne({ + where: { user_id: teacher.user_id }, + }); + + const teacherData = { + ...teacher.toJSON(), + profile: profile ? profile.toJSON() : null, + }; + + await cacheUtils.set(cacheKey, teacherData, 3600); + + res.json({ + success: true, + data: teacherData, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Create new teacher with full profile (users_auth + user_profile + teacher_detail) + */ + async createTeacher(req, res, next) { + try { + const teacherData = req.body; + + // Use service to create full teacher profile + const result = await teacherProfileService.createTeacherWithProfile(teacherData); + + // Clear cache + await cacheUtils.deletePattern('teachers:list:*'); + + res.status(201).json({ + success: true, + message: 'Teacher created successfully', + data: { + user_id: result.user_id, + username: result.username, + email: result.email, + temporary_password: result.temporary_password, // null if user provided password + teacher_code: result.teacher_detail.teacher_code, + teacher_type: result.teacher_detail.teacher_type, + profile: { + full_name: result.profile.full_name, + needs_update: result.profile.etc?.needs_profile_update || false, + } + }, + }); + } catch (error) { + next(error); + } + } + + /** + * Update teacher profile (cho phép user tự cập nhật) + */ + async updateTeacherProfile(req, res, next) { + try { + const { id } = req.params; // user_id or teacher_id + const profileData = req.body; + + // Nếu id là teacher_id, tìm user_id + let userId = id; + if (profileData.teacher_id) { + const teacher = await TeacherDetail.findByPk(profileData.teacher_id); + if (!teacher) { + return res.status(404).json({ + success: false, + message: 'Teacher not found', + }); + } + userId = teacher.user_id; + } + + // Update profile + const updatedProfile = await teacherProfileService.updateTeacherProfile(userId, profileData); + + // Clear cache + await cacheUtils.delete(`teacher:${id}`); + await cacheUtils.delete(`user:profile:${userId}`); + await cacheUtils.deletePattern('teachers:list:*'); + + res.json({ + success: true, + message: 'Teacher profile updated successfully', + data: updatedProfile, + }); + } catch (error) { + next(error); + } + } + + /** + * Update teacher (async via BullMQ) + */ + async updateTeacher(req, res, next) { + try { + const { id } = req.params; + const updates = req.body; + + const job = await addDatabaseWriteJob('update', 'TeacherDetail', { + id, + updates, + }); + + await cacheUtils.delete(`teacher:${id}`); + await cacheUtils.deletePattern('teachers:list:*'); + + res.status(202).json({ + success: true, + message: 'Teacher update job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Delete teacher (update status to resigned) + */ + async deleteTeacher(req, res, next) { + try { + const { id } = req.params; + + const job = await addDatabaseWriteJob('update', 'TeacherDetail', { + id, + updates: { status: 'resigned' }, + }); + + await cacheUtils.delete(`teacher:${id}`); + await cacheUtils.deletePattern('teachers:list:*'); + + res.status(202).json({ + success: true, + message: 'Teacher deletion job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Get teacher datatypes + */ + async getTeacherDatatypes(req, res, next) { + try { + const datatypes = TeacherDetail.rawAttributes; + res.json({ + success: true, + data: datatypes, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new TeacherController(); diff --git a/controllers/trainingController.js b/controllers/trainingController.js new file mode 100644 index 0000000..083467c --- /dev/null +++ b/controllers/trainingController.js @@ -0,0 +1,318 @@ +const { StaffTrainingAssignment, StaffAchievement, UsersAuth, Subject } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob } = require('../config/bullmq'); +const { v4: uuidv4 } = require('uuid'); + +/** + * Training Controller - Quản lý đào tạo nhân sự + */ +class TrainingController { + /** + * GET /api/training/assignments/:staff_id - Danh sách khóa học bắt buộc của nhân viên + */ + async getStaffAssignments(req, res, next) { + try { + const { staff_id } = req.params; + const { status, priority } = req.query; + + const cacheKey = `training:assignments:${staff_id}:${status || 'all'}:${priority || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = { staff_id }; + if (status) where.status = status; + if (priority) where.priority = priority; + + const assignments = await StaffTrainingAssignment.findAll({ + where, + include: [ + { + model: Subject, + as: 'subject', + attributes: ['subject_name', 'subject_code', 'description'], + }, + { + model: UsersAuth, + as: 'assignedBy', + attributes: ['username', 'full_name'], + }, + ], + order: [ + ['priority', 'DESC'], + ['deadline', 'ASC'], + ], + }); + + // Check for overdue assignments + const now = new Date(); + for (const assignment of assignments) { + if (assignment.status !== 'completed' && new Date(assignment.deadline) < now) { + if (assignment.status !== 'overdue') { + await addDatabaseWriteJob('update', 'StaffTrainingAssignment', { + id: assignment.id, + status: 'overdue', + }); + assignment.status = 'overdue'; + } + } + } + + await cacheUtils.set(cacheKey, assignments, 900); // Cache 15 min + + res.json({ + success: true, + data: assignments, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /api/training/assignments - Phân công đào tạo mới + */ + async assignTraining(req, res, next) { + try { + const { staff_id, course_name, subject_id, deadline, priority, notes, assigned_by } = req.body; + + if (!staff_id || !course_name || !deadline || !assigned_by) { + return res.status(400).json({ + success: false, + message: 'Missing required fields: staff_id, course_name, deadline, assigned_by', + }); + } + + const assignmentData = { + id: uuidv4(), + staff_id, + course_name, + subject_id: subject_id || null, + assigned_by, + assigned_date: new Date(), + deadline: new Date(deadline), + status: 'pending', + priority: priority || 'normal', + notes: notes || null, + }; + + // Async write to DB via BullMQ + await addDatabaseWriteJob('create', 'StaffTrainingAssignment', assignmentData); + + // Clear cache + await cacheUtils.del(`training:assignments:${staff_id}:*`); + + res.status(202).json({ + success: true, + message: 'Training assignment request queued', + data: assignmentData, + }); + } catch (error) { + next(error); + } + } + + /** + * PUT /api/training/assignments/:id/status - Cập nhật trạng thái assignment + */ + async updateAssignmentStatus(req, res, next) { + try { + const { id } = req.params; + const { status } = req.body; + + const validStatuses = ['pending', 'in_progress', 'completed', 'overdue']; + if (!validStatuses.includes(status)) { + return res.status(400).json({ + success: false, + message: `Invalid status. Must be one of: ${validStatuses.join(', ')}`, + }); + } + + const assignment = await StaffTrainingAssignment.findByPk(id); + if (!assignment) { + return res.status(404).json({ + success: false, + message: 'Assignment not found', + }); + } + + await addDatabaseWriteJob('update', 'StaffTrainingAssignment', { + id, + status, + }); + + // Clear cache + await cacheUtils.del(`training:assignments:${assignment.staff_id}:*`); + + res.status(202).json({ + success: true, + message: 'Assignment status update queued', + }); + } catch (error) { + next(error); + } + } + + /** + * POST /api/training/achievements - Ghi nhận hoàn thành khóa đào tạo + */ + async createAchievement(req, res, next) { + try { + const { + staff_id, + course_name, + course_id, + completion_date, + certificate_url, + certificate_code, + type, + score, + total_hours, + verified_by, + } = req.body; + + if (!staff_id || !course_name || !completion_date) { + return res.status(400).json({ + success: false, + message: 'Missing required fields: staff_id, course_name, completion_date', + }); + } + + const achievementData = { + id: uuidv4(), + staff_id, + course_name, + course_id: course_id || null, + completion_date: new Date(completion_date), + certificate_url: certificate_url || null, + certificate_code: certificate_code || null, + type: type || 'mandatory', + score: score || null, + total_hours: total_hours || null, + verified_by: verified_by || null, + verified_at: verified_by ? new Date() : null, + }; + + // Async write to DB via BullMQ + await addDatabaseWriteJob('create', 'StaffAchievement', achievementData); + + // Clear cache + await cacheUtils.del(`training:achievements:${staff_id}`); + + res.status(202).json({ + success: true, + message: 'Achievement creation request queued', + data: achievementData, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/training/achievements/:staff_id - Danh sách chứng chỉ của nhân viên + */ + async getStaffAchievements(req, res, next) { + try { + const { staff_id } = req.params; + const { type } = req.query; + + const cacheKey = `training:achievements:${staff_id}:${type || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = { staff_id }; + if (type) where.type = type; + + const achievements = await StaffAchievement.findAll({ + where, + include: [ + { + model: Subject, + as: 'course', + attributes: ['subject_name', 'subject_code'], + }, + { + model: UsersAuth, + as: 'verifiedBy', + attributes: ['username', 'full_name'], + }, + ], + order: [['completion_date', 'DESC']], + }); + + await cacheUtils.set(cacheKey, achievements, 1800); // Cache 30 min + + res.json({ + success: true, + data: achievements, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /api/training/stats - Thống kê đào tạo + */ + async getTrainingStats(req, res, next) { + try { + const cacheKey = 'training:stats'; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const assignmentStats = await StaffTrainingAssignment.findAll({ + attributes: [ + 'status', + [require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'], + ], + group: ['status'], + }); + + const achievementStats = await StaffAchievement.findAll({ + attributes: [ + 'type', + [require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'], + [require('sequelize').fn('SUM', require('sequelize').col('total_hours')), 'total_hours'], + ], + group: ['type'], + }); + + const stats = { + assignments: assignmentStats, + achievements: achievementStats, + }; + + await cacheUtils.set(cacheKey, stats, 300); // Cache 5 min + + res.json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new TrainingController(); diff --git a/controllers/userController.js b/controllers/userController.js new file mode 100644 index 0000000..4d366f6 --- /dev/null +++ b/controllers/userController.js @@ -0,0 +1,495 @@ +const { UsersAuth, UserProfile } = require('../models'); +const { cacheUtils } = require('../config/redis'); +const { addDatabaseWriteJob } = require('../config/bullmq'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); + +// JWT Secret - nên lưu trong environment variable +const JWT_SECRET = process.env.JWT_SECRET || 'sena-secret-key-2026'; +const JWT_EXPIRES_IN = '24h'; + +/** + * User Controller - Quản lý người dùng (Auth + Profile) + */ +class UserController { + /** + * Login - Xác thực người dùng + */ + async login(req, res, next) { + try { + const { username, password } = req.body; + + // Validate input + if (!username || !password) { + return res.status(400).json({ + success: false, + message: 'Username và password là bắt buộc', + }); + } + + // Tìm user theo username hoặc email + const user = await UsersAuth.findOne({ + where: { + [require('sequelize').Op.or]: [ + { username: username }, + { email: username }, + ], + }, + include: [{ + model: UserProfile, + as: 'profile', + }], + }); + + if (!user) { + return res.status(401).json({ + success: false, + message: 'Username hoặc password không đúng', + }); + } + + // Kiểm tra tài khoản có bị khóa không + if (user.is_locked) { + if (user.locked_until && new Date() < new Date(user.locked_until)) { + return res.status(403).json({ + success: false, + message: 'Tài khoản bị khóa đến ' + user.locked_until, + }); + } else { + // Mở khóa nếu hết thời gian khóa + await user.update({ + is_locked: false, + locked_until: null, + failed_login_attempts: 0 + }); + } + } + + // Kiểm tra tài khoản có active không + if (!user.is_active) { + return res.status(403).json({ + success: false, + message: 'Tài khoản đã bị vô hiệu hóa', + }); + } + + // Verify password + const passwordMatch = await bcrypt.compare(password + user.salt, user.password_hash); + + if (!passwordMatch) { + // Tăng số lần đăng nhập thất bại + const failedAttempts = user.failed_login_attempts + 1; + const updates = { failed_login_attempts: failedAttempts }; + + // Khóa tài khoản sau 5 lần thất bại + if (failedAttempts >= 5) { + updates.is_locked = true; + updates.locked_until = new Date(Date.now() + 30 * 60 * 1000); // Khóa 30 phút + } + + await user.update(updates); + + return res.status(401).json({ + success: false, + message: 'Username hoặc password không đúng', + attemptsLeft: Math.max(0, 5 - failedAttempts), + }); + } + + // Đăng nhập thành công - Reset failed attempts + const sessionId = crypto.randomUUID(); + const clientIp = req.ip || req.connection.remoteAddress; + + await user.update({ + failed_login_attempts: 0, + login_count: user.login_count + 1, + last_login: new Date(), + last_login_ip: clientIp, + current_session_id: sessionId, + }); + + // Tạo JWT token + const token = jwt.sign( + { + userId: user.id, + username: user.username, + email: user.email, + sessionId: sessionId, + }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); + + // Return success response + res.json({ + success: true, + message: 'Đăng nhập thành công', + data: { + token, + user: { + id: user.id, + username: user.username, + email: user.email, + profile: user.profile, + last_login: user.last_login, + login_count: user.login_count, + }, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * Register - Tạo tài khoản mới + */ + async register(req, res, next) { + try { + const { username, email, password, full_name, phone, school_id } = req.body; + + // Validate input + if (!username || !email || !password) { + return res.status(400).json({ + success: false, + message: 'Username, email và password là bắt buộc', + }); + } + + // Kiểm tra username đã tồn tại + const existingUser = await UsersAuth.findOne({ + where: { + [require('sequelize').Op.or]: [ + { username }, + { email }, + ], + }, + }); + + if (existingUser) { + return res.status(409).json({ + success: false, + message: 'Username hoặc email đã tồn tại', + }); + } + + // Hash password + const salt = crypto.randomBytes(16).toString('hex'); + const passwordHash = await bcrypt.hash(password + salt, 10); + + // Tạo user mới + const newUser = await UsersAuth.create({ + username, + email, + password_hash: passwordHash, + salt, + qr_secret: crypto.randomBytes(32).toString('hex'), + }); + + // Tạo profile nếu có thông tin + if (full_name || phone || school_id) { + await UserProfile.create({ + user_id: newUser.id, + full_name: full_name || username, + phone, + school_id, + }); + } + + res.status(201).json({ + success: true, + message: 'Đăng ký tài khoản thành công', + data: { + id: newUser.id, + username: newUser.username, + email: newUser.email, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * Verify Token - Xác thực JWT token + */ + async verifyToken(req, res, next) { + try { + const token = req.headers.authorization?.replace('Bearer ', ''); + + if (!token) { + return res.status(401).json({ + success: false, + message: 'Token không được cung cấp', + }); + } + + const decoded = jwt.verify(token, JWT_SECRET); + + // Kiểm tra user còn tồn tại và session hợp lệ + const user = await UsersAuth.findByPk(decoded.userId, { + include: [{ + model: UserProfile, + as: 'profile', + }], + }); + + if (!user || !user.is_active) { + return res.status(401).json({ + success: false, + message: 'Token không hợp lệ hoặc tài khoản đã bị vô hiệu hóa', + }); + } + + if (user.current_session_id !== decoded.sessionId) { + return res.status(401).json({ + success: false, + message: 'Phiên đăng nhập đã hết hạn', + }); + } + + res.json({ + success: true, + message: 'Token hợp lệ', + data: { + user: { + id: user.id, + username: user.username, + email: user.email, + profile: user.profile, + }, + }, + }); + } catch (error) { + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ + success: false, + message: 'Token không hợp lệ hoặc đã hết hạn', + }); + } + next(error); + } + } + + /** + * Logout - Đăng xuất + */ + async logout(req, res, next) { + try { + const token = req.headers.authorization?.replace('Bearer ', ''); + + if (!token) { + return res.status(401).json({ + success: false, + message: 'Token không được cung cấp', + }); + } + + const decoded = jwt.verify(token, JWT_SECRET); + + // Xóa session hiện tại + await UsersAuth.update( + { current_session_id: null }, + { where: { id: decoded.userId } } + ); + + res.json({ + success: true, + message: 'Đăng xuất thành công', + }); + } catch (error) { + next(error); + } + } + + /** + * Get all users with pagination and caching + */ + async getAllUsers(req, res, next) { + try { + const { page = 1, limit = 20, is_active, school_id } = req.query; + const offset = (page - 1) * limit; + + const cacheKey = `users:list:${page}:${limit}:${is_active || 'all'}:${school_id || 'all'}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const where = {}; + if (is_active !== undefined) where.is_active = is_active === 'true'; + + const { count, rows } = await UsersAuth.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + include: [{ + model: UserProfile, + as: 'profile', + attributes: ['full_name', 'phone', 'school_id'], + where: school_id ? { school_id } : undefined, + }], + order: [['created_at', 'DESC']], + }); + + const result = { + users: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit), + }, + }; + + await cacheUtils.set(cacheKey, result, 1800); + + res.json({ + success: true, + data: result, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Get user by ID with profile + */ + async getUserById(req, res, next) { + try { + const { id } = req.params; + const cacheKey = `user:${id}`; + + const cached = await cacheUtils.get(cacheKey); + if (cached) { + return res.json({ + success: true, + data: cached, + cached: true, + }); + } + + const user = await UsersAuth.findByPk(id, { + include: [{ + model: UserProfile, + as: 'profile', + }], + }); + + if (!user) { + return res.status(404).json({ + success: false, + message: 'User not found', + }); + } + + await cacheUtils.set(cacheKey, user, 3600); + + res.json({ + success: true, + data: user, + cached: false, + }); + } catch (error) { + next(error); + } + } + + /** + * Create new user (async via BullMQ) + */ + async createUser(req, res, next) { + try { + const userData = req.body; + + const job = await addDatabaseWriteJob('create', 'UsersAuth', userData); + + await cacheUtils.deletePattern('users:list:*'); + + res.status(202).json({ + success: true, + message: 'User creation job queued', + jobId: job.id, + data: userData, + }); + } catch (error) { + next(error); + } + } + + /** + * Update user (async via BullMQ) + */ + async updateUser(req, res, next) { + try { + const { id } = req.params; + const updates = req.body; + + const job = await addDatabaseWriteJob('update', 'UsersAuth', { + id, + updates, + }); + + await cacheUtils.delete(`user:${id}`); + await cacheUtils.deletePattern('users:list:*'); + + res.status(202).json({ + success: true, + message: 'User update job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Delete user (soft delete via is_active) + */ + async deleteUser(req, res, next) { + try { + const { id } = req.params; + + const job = await addDatabaseWriteJob('update', 'UsersAuth', { + id, + updates: { is_active: false }, + }); + + await cacheUtils.delete(`user:${id}`); + await cacheUtils.deletePattern('users:list:*'); + + res.status(202).json({ + success: true, + message: 'User deletion job queued', + jobId: job.id, + }); + } catch (error) { + next(error); + } + } + + /** + * Get user datatypes + */ + async getUserDatatypes(req, res, next) { + try { + const datatypes = UsersAuth.rawAttributes; + res.json({ + success: true, + data: datatypes, + }); + } catch (error) { + next(error); + } + } +} + +module.exports = new UserController(); diff --git a/middleware/errorHandler.js b/middleware/errorHandler.js new file mode 100644 index 0000000..19f58e5 --- /dev/null +++ b/middleware/errorHandler.js @@ -0,0 +1,144 @@ +/** + * Error Handler Middleware + * Xử lý tất cả errors trong ứng dụng + */ + +class AppError extends Error { + constructor(message, statusCode = 500, isOperational = true) { + super(message); + this.statusCode = statusCode; + this.isOperational = isOperational; + this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; + + Error.captureStackTrace(this, this.constructor); + } +} + +/** + * Handle Sequelize Validation Errors + */ +const handleSequelizeValidationError = (err) => { + const errors = err.errors.map(e => ({ + field: e.path, + message: e.message, + })); + + return new AppError( + 'Validation Error', + 400, + true, + errors + ); +}; + +/** + * Handle Sequelize Unique Constraint Error + */ +const handleSequelizeUniqueError = (err) => { + const field = err.errors[0]?.path || 'field'; + const message = `${field} already exists`; + + return new AppError(message, 409); +}; + +/** + * Handle Sequelize Foreign Key Error + */ +const handleSequelizeForeignKeyError = (err) => { + return new AppError('Referenced record not found', 404); +}; + +/** + * Development Error Response + */ +const sendErrorDev = (err, res) => { + res.status(err.statusCode).json({ + success: false, + status: err.status, + error: err, + message: err.message, + stack: err.stack, + }); +}; + +/** + * Production Error Response + */ +const sendErrorProd = (err, res) => { + // Operational, trusted error: send message to client + if (err.isOperational) { + res.status(err.statusCode).json({ + success: false, + status: err.status, + message: err.message, + errors: err.errors, + }); + } + // Programming or unknown error: don't leak error details + else { + console.error('❌ ERROR:', err); + + res.status(500).json({ + success: false, + status: 'error', + message: 'Something went wrong!', + }); + } +}; + +/** + * Global Error Handler Middleware + */ +const errorHandler = (err, req, res, next) => { + err.statusCode = err.statusCode || 500; + err.status = err.status || 'error'; + + if (process.env.NODE_ENV === 'development') { + sendErrorDev(err, res); + } else { + let error = { ...err }; + error.message = err.message; + + // Handle specific Sequelize errors + if (err.name === 'SequelizeValidationError') { + error = handleSequelizeValidationError(err); + } + + if (err.name === 'SequelizeUniqueConstraintError') { + error = handleSequelizeUniqueError(err); + } + + if (err.name === 'SequelizeForeignKeyConstraintError') { + error = handleSequelizeForeignKeyError(err); + } + + sendErrorProd(error, res); + } +}; + +/** + * 404 Handler + */ +const notFoundHandler = (req, res, next) => { + const err = new AppError( + `Cannot find ${req.originalUrl} on this server`, + 404 + ); + next(err); +}; + +/** + * Async Error Wrapper + */ +const catchAsync = (fn) => { + return (req, res, next) => { + fn(req, res, next).catch(next); + }; +}; + +module.exports = { + AppError, + errorHandler, + notFoundHandler, + catchAsync, +}; diff --git a/models/AcademicYear.js b/models/AcademicYear.js new file mode 100644 index 0000000..523ef59 --- /dev/null +++ b/models/AcademicYear.js @@ -0,0 +1,19 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const AcademicYear = sequelize.define('academic_years', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + year_name: { type: DataTypes.STRING(50), allowNull: false }, + start_date: { type: DataTypes.DATE, allowNull: false }, + end_date: { type: DataTypes.DATE, allowNull: false }, + semester_count: { type: DataTypes.INTEGER, defaultValue: 2 }, + is_current: { type: DataTypes.BOOLEAN, defaultValue: false }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'academic_years', + timestamps: true, + underscored: true, +}); + +module.exports = AcademicYear; diff --git a/models/AttendanceDaily.js b/models/AttendanceDaily.js new file mode 100644 index 0000000..7b93e5b --- /dev/null +++ b/models/AttendanceDaily.js @@ -0,0 +1,22 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const AttendanceDaily = sequelize.define('attendance_daily', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + user_id: { type: DataTypes.UUID, allowNull: false }, + school_id: { type: DataTypes.UUID, allowNull: false }, + class_id: { type: DataTypes.UUID }, + attendance_date: { type: DataTypes.DATE, allowNull: false }, + status: { type: DataTypes.ENUM('present', 'absent', 'late', 'excused'), allowNull: false }, + check_in_time: { type: DataTypes.TIME }, + check_out_time: { type: DataTypes.TIME }, + notes: { type: DataTypes.TEXT }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'attendance_daily', + timestamps: true, + underscored: true, +}); + +module.exports = AttendanceDaily; diff --git a/models/AttendanceLog.js b/models/AttendanceLog.js new file mode 100644 index 0000000..e805d58 --- /dev/null +++ b/models/AttendanceLog.js @@ -0,0 +1,90 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * AttendanceLogs Model - Dữ liệu thô điểm danh từ QR + * Bảng này cần partition theo thời gian do data lớn + */ +const AttendanceLog = sequelize.define('attendance_logs', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + user_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users_auth', + key: 'id', + }, + comment: 'ID người dùng (học sinh/giáo viên)', + }, + school_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'schools', + key: 'id', + }, + comment: 'ID trường', + }, + check_time: { + type: DataTypes.DATE, + allowNull: false, + comment: 'Thời gian quét QR', + }, + check_type: { + type: DataTypes.ENUM('IN', 'OUT'), + allowNull: false, + comment: 'Loại: Vào hoặc Ra', + }, + device_id: { + type: DataTypes.STRING(100), + comment: 'ID thiết bị quét', + }, + location: { + type: DataTypes.STRING(200), + comment: 'Vị trí quét (cổng, lớp học)', + }, + qr_version: { + type: DataTypes.INTEGER, + comment: 'Version QR code', + }, + ip_address: { + type: DataTypes.STRING(45), + comment: 'IP address thiết bị (IPv4/IPv6)', + }, + user_agent: { + type: DataTypes.TEXT, + comment: 'User agent', + }, + is_valid: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: 'QR code hợp lệ', + }, + notes: { + type: DataTypes.TEXT, + comment: 'Ghi chú', + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'attendance_logs', + timestamps: false, + underscored: true, + indexes: [ + { fields: ['user_id', 'check_time'] }, + { fields: ['school_id', 'check_time'] }, + { fields: ['check_time'] }, + { fields: ['device_id'] }, + { fields: ['is_valid'] }, + ], + comment: 'Bảng log điểm danh thô - Cần partition theo tháng', +}); + +module.exports = AttendanceLog; diff --git a/models/AuditLog.js b/models/AuditLog.js new file mode 100644 index 0000000..5561ae3 --- /dev/null +++ b/models/AuditLog.js @@ -0,0 +1,35 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const AuditLog = sequelize.define('audit_logs', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + school_id: { type: DataTypes.UUID }, + user_id: { type: DataTypes.UUID }, + action: { type: DataTypes.STRING(100), allowNull: false }, + entity_type: { type: DataTypes.STRING(100) }, + entity_id: { type: DataTypes.UUID }, + old_values: { type: DataTypes.JSON }, + new_values: { type: DataTypes.JSON }, + ip_address: { type: DataTypes.STRING(45) }, + user_agent: { type: DataTypes.TEXT }, + status: { + type: DataTypes.ENUM('success', 'failed'), + defaultValue: 'success' + }, + error_message: { type: DataTypes.TEXT }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'audit_logs', + timestamps: false, + createdAt: 'created_at', + updatedAt: false, + indexes: [ + { fields: ['school_id'] }, + { fields: ['user_id'] }, + { fields: ['action'] }, + { fields: ['entity_type', 'entity_id'] }, + { fields: ['created_at'] }, + ], +}); + +module.exports = AuditLog; diff --git a/models/Chapter.js b/models/Chapter.js new file mode 100644 index 0000000..f14d87a --- /dev/null +++ b/models/Chapter.js @@ -0,0 +1,69 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * Chapter Model - Chương trong giáo trình + * Một Subject có nhiều Chapter + */ +const Chapter = sequelize.define('chapters', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + subject_id: { + type: DataTypes.UUID, + allowNull: false, + comment: 'ID của giáo trình' + }, + chapter_number: { + type: DataTypes.INTEGER, + allowNull: false, + comment: 'Số thứ tự chương (1, 2, 3...)' + }, + chapter_title: { + type: DataTypes.STRING(200), + allowNull: false, + comment: 'Tiêu đề chương' + }, + chapter_description: { + type: DataTypes.TEXT, + comment: 'Mô tả về chương học' + }, + duration_minutes: { + type: DataTypes.INTEGER, + comment: 'Thời lượng học (phút)' + }, + is_published: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: 'Đã xuất bản chưa' + }, + display_order: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: 'Thứ tự hiển thị' + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, +}, { + tableName: 'chapters', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['subject_id'] }, + { fields: ['subject_id', 'chapter_number'], unique: true }, + { fields: ['display_order'] }, + { fields: ['is_published'] }, + ], +}); + +module.exports = Chapter; diff --git a/models/Class.js b/models/Class.js new file mode 100644 index 0000000..65f9b7e --- /dev/null +++ b/models/Class.js @@ -0,0 +1,22 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const Class = sequelize.define('classes', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + class_code: { type: DataTypes.STRING(20), unique: true, allowNull: false }, + class_name: { type: DataTypes.STRING(100), allowNull: false }, + school_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'schools', key: 'id' } }, + academic_year_id: { type: DataTypes.UUID, allowNull: false }, + grade_level: { type: DataTypes.INTEGER }, + homeroom_teacher_id: { type: DataTypes.UUID }, + max_students: { type: DataTypes.INTEGER, defaultValue: 30 }, + current_students: { type: DataTypes.INTEGER, defaultValue: 0 }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'classes', + timestamps: true, + underscored: true, +}); + +module.exports = Class; diff --git a/models/ClassSchedule.js b/models/ClassSchedule.js new file mode 100644 index 0000000..73415a1 --- /dev/null +++ b/models/ClassSchedule.js @@ -0,0 +1,22 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const ClassSchedule = sequelize.define('class_schedules', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + class_id: { type: DataTypes.UUID, allowNull: false }, + subject_id: { type: DataTypes.UUID, allowNull: false }, + teacher_id: { type: DataTypes.UUID }, + room_id: { type: DataTypes.UUID }, + day_of_week: { type: DataTypes.INTEGER, comment: '1=Monday, 7=Sunday' }, + period: { type: DataTypes.INTEGER, comment: 'Tiết học' }, + start_time: { type: DataTypes.TIME }, + end_time: { type: DataTypes.TIME }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'class_schedules', + timestamps: true, + underscored: true, +}); + +module.exports = ClassSchedule; diff --git a/models/Game.js b/models/Game.js new file mode 100644 index 0000000..ebc711e --- /dev/null +++ b/models/Game.js @@ -0,0 +1,101 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * Game Model - Trò chơi giáo dục + * Các game engine/template để render nội dung học tập + * Một game có thể render nhiều lesson khác nhau có cùng type + */ +const Game = sequelize.define('games', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: { + type: DataTypes.STRING(200), + allowNull: false, + comment: 'Tên trò chơi' + }, + description: { + type: DataTypes.TEXT, + comment: 'Mô tả về trò chơi' + }, + url: { + type: DataTypes.TEXT, + allowNull: false, + comment: 'URL của game (HTML5 game, Unity WebGL, etc.)' + }, + thumbnail: { + type: DataTypes.TEXT, + comment: 'URL ảnh thumbnail' + }, + type: { + type: DataTypes.STRING(50), + allowNull: false, + comment: 'Loại game: counting_quiz, math_game, word_puzzle, etc. - Must match lesson content_json.type' + }, + config: { + type: DataTypes.JSON, + comment: 'Cấu hình game: controls, settings, features, etc.' + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: 'Game có sẵn sàng để sử dụng không' + }, + is_premium: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: 'Yêu cầu premium để chơi' + }, + min_grade: { + type: DataTypes.INTEGER, + comment: 'Cấp lớp tối thiểu (1-12)' + }, + max_grade: { + type: DataTypes.INTEGER, + comment: 'Cấp lớp tối đa (1-12)' + }, + difficulty_level: { + type: DataTypes.ENUM('easy', 'medium', 'hard'), + comment: 'Độ khó' + }, + play_count: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: 'Số lần đã chơi' + }, + rating: { + type: DataTypes.DECIMAL(3, 2), + comment: 'Đánh giá (0.00 - 5.00)' + }, + display_order: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: 'Thứ tự hiển thị' + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, +}, { + tableName: 'games', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['type'] }, + { fields: ['is_active'] }, + { fields: ['is_premium'] }, + { fields: ['display_order'] }, + { fields: ['difficulty_level'] }, + ], +}); + +module.exports = Game; diff --git a/models/Grade.js b/models/Grade.js new file mode 100644 index 0000000..292a8c2 --- /dev/null +++ b/models/Grade.js @@ -0,0 +1,100 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * Grades Model - Điểm số chi tiết của học sinh + * Bảng lớn nhất trong hệ thống + */ +const Grade = sequelize.define('grades', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + grade_item_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'grade_items', + key: 'id', + }, + comment: 'ID cột điểm', + }, + student_id: { + type: DataTypes.UUID, + allowNull: false, + comment: 'ID học sinh', + }, + class_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'classes', + key: 'id', + }, + comment: 'ID lớp học', + }, + score: { + type: DataTypes.DECIMAL(5, 2), + comment: 'Điểm số', + validate: { + min: 0, + max: 100, + }, + }, + grade_level: { + type: DataTypes.STRING(5), + comment: 'Xếp loại (A, B, C, D, F)', + }, + comment: { + type: DataTypes.TEXT, + comment: 'Nhận xét của giáo viên', + }, + graded_by: { + type: DataTypes.UUID, + comment: 'ID giáo viên chấm điểm', + }, + graded_at: { + type: DataTypes.DATE, + comment: 'Thời gian chấm điểm', + }, + is_published: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: 'Đã công bố cho học sinh/phụ huynh', + }, + published_at: { + type: DataTypes.DATE, + comment: 'Thời gian công bố', + }, + is_locked: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: 'Khóa điểm, không cho sửa', + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'grades', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['grade_item_id', 'student_id'], unique: true }, + { fields: ['student_id', 'class_id'] }, + { fields: ['class_id'] }, + { fields: ['graded_by'] }, + { fields: ['is_published'] }, + { fields: ['created_at'] }, + ], + comment: 'Bảng điểm chi tiết - Dữ liệu lớn nhất hệ thống', +}); + +module.exports = Grade; diff --git a/models/GradeCategory.js b/models/GradeCategory.js new file mode 100644 index 0000000..b645bb7 --- /dev/null +++ b/models/GradeCategory.js @@ -0,0 +1,19 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const GradeCategory = sequelize.define('grade_categories', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + category_code: { type: DataTypes.STRING(20), allowNull: false }, + category_name: { type: DataTypes.STRING(100), allowNull: false }, + subject_id: { type: DataTypes.UUID }, + weight: { type: DataTypes.DECIMAL(5, 2), comment: 'Trọng số %' }, + description: { type: DataTypes.TEXT }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'grade_categories', + timestamps: true, + underscored: true, +}); + +module.exports = GradeCategory; diff --git a/models/GradeHistory.js b/models/GradeHistory.js new file mode 100644 index 0000000..578b4b9 --- /dev/null +++ b/models/GradeHistory.js @@ -0,0 +1,21 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const GradeHistory = sequelize.define('grade_histories', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + grade_id: { type: DataTypes.UUID, allowNull: false }, + student_id: { type: DataTypes.UUID, allowNull: false }, + old_score: { type: DataTypes.DECIMAL(5, 2) }, + new_score: { type: DataTypes.DECIMAL(5, 2) }, + changed_by: { type: DataTypes.UUID }, + change_reason: { type: DataTypes.TEXT }, + changed_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'grade_histories', + timestamps: true, + underscored: true, +}); + +module.exports = GradeHistory; diff --git a/models/GradeItem.js b/models/GradeItem.js new file mode 100644 index 0000000..ca74f09 --- /dev/null +++ b/models/GradeItem.js @@ -0,0 +1,29 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const GradeItem = sequelize.define('grade_items', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + item_name: { type: DataTypes.STRING(200), allowNull: false }, + category_id: { type: DataTypes.UUID, allowNull: false }, + class_id: { type: DataTypes.UUID, allowNull: false }, + max_score: { type: DataTypes.DECIMAL(5, 2), defaultValue: 100 }, + exam_date: { type: DataTypes.DATE }, + description: { type: DataTypes.TEXT }, + is_premium: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Nội dung premium' }, + is_training: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Nội dung đào tạo nhân sự' }, + is_public: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Nội dung công khai' }, + min_subscription_tier: { type: DataTypes.STRING(50), comment: 'Gói tối thiểu' }, + min_interaction_time: { type: DataTypes.INTEGER, defaultValue: 0, comment: 'Thời gian tương tác tối thiểu (giây)' }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'grade_items', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['is_premium'] }, + { fields: ['is_training'] }, + ], +}); + +module.exports = GradeItem; diff --git a/models/GradeSummary.js b/models/GradeSummary.js new file mode 100644 index 0000000..6c77ec5 --- /dev/null +++ b/models/GradeSummary.js @@ -0,0 +1,20 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const GradeSummary = sequelize.define('grade_summaries', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + student_id: { type: DataTypes.UUID, allowNull: false }, + subject_id: { type: DataTypes.UUID, allowNull: false }, + academic_year_id: { type: DataTypes.UUID, allowNull: false }, + semester: { type: DataTypes.INTEGER }, + average_score: { type: DataTypes.DECIMAL(5, 2) }, + final_grade: { type: DataTypes.STRING(5) }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'grade_summaries', + timestamps: true, + underscored: true, +}); + +module.exports = GradeSummary; diff --git a/models/LeaveRequest.js b/models/LeaveRequest.js new file mode 100644 index 0000000..d533557 --- /dev/null +++ b/models/LeaveRequest.js @@ -0,0 +1,22 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const LeaveRequest = sequelize.define('leave_requests', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + student_id: { type: DataTypes.UUID, allowNull: false }, + requested_by: { type: DataTypes.UUID, allowNull: false, comment: 'Parent user_id' }, + leave_from: { type: DataTypes.DATE, allowNull: false }, + leave_to: { type: DataTypes.DATE, allowNull: false }, + reason: { type: DataTypes.TEXT, allowNull: false }, + status: { type: DataTypes.ENUM('pending', 'approved', 'rejected'), defaultValue: 'pending' }, + approved_by: { type: DataTypes.UUID }, + approved_at: { type: DataTypes.DATE }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'leave_requests', + timestamps: true, + underscored: true, +}); + +module.exports = LeaveRequest; diff --git a/models/Lesson.js b/models/Lesson.js new file mode 100644 index 0000000..8403838 --- /dev/null +++ b/models/Lesson.js @@ -0,0 +1,101 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * Lesson Model - Bài học trong chương + * Một Chapter có nhiều Lesson + */ +const Lesson = sequelize.define('lessons', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + chapter_id: { + type: DataTypes.UUID, + allowNull: false, + comment: 'ID của chương' + }, + lesson_number: { + type: DataTypes.INTEGER, + allowNull: false, + comment: 'Số thứ tự bài học (1, 2, 3...)' + }, + lesson_title: { + type: DataTypes.STRING(200), + allowNull: false, + comment: 'Tiêu đề bài học' + }, + lesson_type: { + type: DataTypes.ENUM('json_content', 'url_content'), + defaultValue: 'json_content', + comment: 'Loại bài học: json_content (nội dung JSON) hoặc url_content (URL)' + }, + lesson_description: { + type: DataTypes.TEXT, + comment: 'Mô tả bài học' + }, + + // Dạng 1: JSON Content - Nội dung học tập dạng JSON + content_json: { + type: DataTypes.JSON, + comment: 'Nội dung học tập dạng JSON: text, quiz, interactive, assignment, etc.' + }, + + // Dạng 2: URL Content - Chứa link external + content_url: { + type: DataTypes.STRING(500), + comment: 'URL nội dung: video, audio, document, external link' + }, + content_type: { + type: DataTypes.STRING(50), + comment: 'Loại content cho URL: video, audio, pdf, external_link, youtube, etc.' + }, + + duration_minutes: { + type: DataTypes.INTEGER, + comment: 'Thời lượng (phút)' + }, + is_published: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: 'Đã xuất bản' + }, + is_free: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: 'Cho phép học thử miễn phí' + }, + display_order: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: 'Thứ tự hiển thị' + }, + thumbnail_url: { + type: DataTypes.STRING(500), + comment: 'URL ảnh thumbnail' + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, +}, { + tableName: 'lessons', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['chapter_id'] }, + { fields: ['chapter_id', 'lesson_number'], unique: true }, + { fields: ['display_order'] }, + { fields: ['is_published'] }, + { fields: ['lesson_type'] }, + ], +}); + +module.exports = Lesson; diff --git a/models/Message.js b/models/Message.js new file mode 100644 index 0000000..c9d073a --- /dev/null +++ b/models/Message.js @@ -0,0 +1,36 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const Message = sequelize.define('messages', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + school_id: { type: DataTypes.UUID }, + sender_id: { type: DataTypes.UUID, allowNull: false }, + receiver_id: { type: DataTypes.UUID }, + receiver_role: { type: DataTypes.STRING(50) }, + subject: { type: DataTypes.STRING(255) }, + content: { type: DataTypes.TEXT, allowNull: false }, + message_type: { + type: DataTypes.ENUM('direct', 'broadcast', 'class_message', 'parent_teacher'), + defaultValue: 'direct' + }, + parent_message_id: { type: DataTypes.UUID }, + attachments: { type: DataTypes.JSON }, + is_read: { type: DataTypes.BOOLEAN, defaultValue: false }, + read_at: { type: DataTypes.DATE }, + is_archived: { type: DataTypes.BOOLEAN, defaultValue: false }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'messages', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['school_id'] }, + { fields: ['sender_id'] }, + { fields: ['receiver_id'] }, + { fields: ['is_read'] }, + { fields: ['parent_message_id'] }, + ], +}); + +module.exports = Message; diff --git a/models/Notification.js b/models/Notification.js new file mode 100644 index 0000000..926a026 --- /dev/null +++ b/models/Notification.js @@ -0,0 +1,40 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const Notification = sequelize.define('notifications', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + school_id: { type: DataTypes.UUID }, + title: { type: DataTypes.STRING(255), allowNull: false }, + content: { type: DataTypes.TEXT }, + notification_type: { + type: DataTypes.ENUM('announcement', 'event', 'emergency', 'reminder', 'grade_update', 'attendance_alert'), + defaultValue: 'announcement' + }, + priority: { + type: DataTypes.ENUM('low', 'normal', 'high', 'urgent'), + defaultValue: 'normal' + }, + target_role: { type: DataTypes.STRING(50) }, + target_users: { type: DataTypes.JSON }, + scheduled_at: { type: DataTypes.DATE }, + sent_at: { type: DataTypes.DATE }, + created_by: { type: DataTypes.UUID }, + status: { + type: DataTypes.ENUM('draft', 'scheduled', 'sent', 'cancelled'), + defaultValue: 'draft' + }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'notifications', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['school_id'] }, + { fields: ['notification_type'] }, + { fields: ['status'] }, + { fields: ['scheduled_at'] }, + ], +}); + +module.exports = Notification; diff --git a/models/NotificationLog.js b/models/NotificationLog.js new file mode 100644 index 0000000..8f74bb8 --- /dev/null +++ b/models/NotificationLog.js @@ -0,0 +1,33 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const NotificationLog = sequelize.define('notification_logs', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + notification_id: { type: DataTypes.UUID, allowNull: false }, + user_id: { type: DataTypes.UUID, allowNull: false }, + sent_at: { type: DataTypes.DATE }, + read_at: { type: DataTypes.DATE }, + delivery_status: { + type: DataTypes.ENUM('pending', 'sent', 'delivered', 'failed', 'read'), + defaultValue: 'pending' + }, + delivery_channel: { + type: DataTypes.ENUM('in_app', 'email', 'sms', 'push'), + defaultValue: 'in_app' + }, + error_message: { type: DataTypes.TEXT }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'notification_logs', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['notification_id'] }, + { fields: ['user_id'] }, + { fields: ['delivery_status'] }, + { fields: ['read_at'] }, + ], +}); + +module.exports = NotificationLog; diff --git a/models/ParentAssignedTask.js b/models/ParentAssignedTask.js new file mode 100644 index 0000000..8be050d --- /dev/null +++ b/models/ParentAssignedTask.js @@ -0,0 +1,95 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * ParentAssignedTask Model - Phụ huynh gán bài tập cho con + */ +const ParentAssignedTask = sequelize.define('parent_assigned_tasks', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + parent_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users_auth', + key: 'id', + }, + comment: 'Phụ huynh gán', + }, + student_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'student_details', + key: 'id', + }, + comment: 'Học sinh được gán', + }, + grade_item_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'grade_items', + key: 'id', + }, + comment: 'Bài tập được gán', + }, + assigned_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + comment: 'Thời gian gán', + }, + due_date: { + type: DataTypes.DATEONLY, + comment: 'Hạn hoàn thành', + }, + status: { + type: DataTypes.ENUM('pending', 'in_progress', 'completed', 'overdue'), + defaultValue: 'pending', + comment: 'Trạng thái', + }, + message: { + type: DataTypes.TEXT, + comment: 'Lời nhắn của phụ huynh', + }, + priority: { + type: DataTypes.ENUM('low', 'normal', 'high'), + defaultValue: 'normal', + comment: 'Độ ưu tiên', + }, + completion_date: { + type: DataTypes.DATE, + comment: 'Ngày hoàn thành', + }, + student_notes: { + type: DataTypes.TEXT, + comment: 'Ghi chú của học sinh khi hoàn thành', + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'parent_assigned_tasks', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['parent_id'] }, + { fields: ['student_id'] }, + { fields: ['status'] }, + { fields: ['due_date'] }, + ], + comment: 'Bảng quản lý bài tập phụ huynh gán cho con', +}); + +module.exports = ParentAssignedTask; diff --git a/models/ParentStudentMap.js b/models/ParentStudentMap.js new file mode 100644 index 0000000..703e184 --- /dev/null +++ b/models/ParentStudentMap.js @@ -0,0 +1,24 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const ParentStudentMap = sequelize.define('parent_student_map', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + parent_id: { type: DataTypes.UUID, allowNull: false }, + student_id: { type: DataTypes.UUID, allowNull: false }, + relationship: { type: DataTypes.ENUM('father', 'mother', 'guardian', 'other'), allowNull: false }, + is_primary_contact: { type: DataTypes.BOOLEAN, defaultValue: false }, + can_assign_tasks: { type: DataTypes.BOOLEAN, defaultValue: true, comment: 'Quyền gán bài tập' }, + can_view_detailed_grades: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Xem điểm chi tiết' }, + can_communicate_teacher: { type: DataTypes.BOOLEAN, defaultValue: true, comment: 'Liên hệ giáo viên' }, + permission_level: { type: DataTypes.ENUM('limited', 'standard', 'full'), defaultValue: 'standard', comment: 'Cấp quyền' }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'parent_student_map', + timestamps: false, + underscored: true, + indexes: [ + { fields: ['can_assign_tasks', 'can_view_detailed_grades'] }, + ], +}); + +module.exports = ParentStudentMap; diff --git a/models/Permission.js b/models/Permission.js new file mode 100644 index 0000000..150874d --- /dev/null +++ b/models/Permission.js @@ -0,0 +1,24 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const Permission = sequelize.define('permissions', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + permission_code: { type: DataTypes.STRING(50), unique: true, allowNull: false }, + permission_name: { type: DataTypes.STRING(100), allowNull: false }, + permission_description: { type: DataTypes.TEXT }, + resource: { type: DataTypes.STRING(50), comment: 'Tài nguyên (students, grades, etc.)' }, + action: { type: DataTypes.STRING(50), comment: 'Hành động (view, create, edit, delete)' }, + is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'permissions', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['permission_code'], unique: true }, + { fields: ['resource', 'action'] }, + ], +}); + +module.exports = Permission; diff --git a/models/Role.js b/models/Role.js new file mode 100644 index 0000000..cd40c7e --- /dev/null +++ b/models/Role.js @@ -0,0 +1,64 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * Roles Model - Danh sách 9 vai trò trong hệ thống + */ +const Role = sequelize.define('roles', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + role_code: { + type: DataTypes.STRING(50), + unique: true, + allowNull: false, + comment: 'Mã vai trò (system_admin, center_manager, etc.)', + }, + role_name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: 'Tên vai trò', + }, + role_description: { + type: DataTypes.TEXT, + comment: 'Mô tả vai trò', + }, + level: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: 'Cấp độ vai trò (0=cao nhất)', + }, + is_system_role: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: 'Vai trò hệ thống không thể xóa', + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'roles', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['role_code'], unique: true }, + { fields: ['level'] }, + { fields: ['is_active'] }, + ], + comment: 'Bảng quản lý vai trò (9 vai trò)', +}); + +module.exports = Role; diff --git a/models/RolePermission.js b/models/RolePermission.js new file mode 100644 index 0000000..bb57187 --- /dev/null +++ b/models/RolePermission.js @@ -0,0 +1,18 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const RolePermission = sequelize.define('role_permissions', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + role_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'roles', key: 'id' } }, + permission_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'permissions', key: 'id' } }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'role_permissions', + timestamps: false, + underscored: true, + indexes: [ + { fields: ['role_id', 'permission_id'], unique: true }, + ], +}); + +module.exports = RolePermission; diff --git a/models/Room.js b/models/Room.js new file mode 100644 index 0000000..a6e52ce --- /dev/null +++ b/models/Room.js @@ -0,0 +1,20 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const Room = sequelize.define('rooms', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + room_code: { type: DataTypes.STRING(20), allowNull: false }, + room_name: { type: DataTypes.STRING(100), allowNull: false }, + school_id: { type: DataTypes.UUID, allowNull: false }, + room_type: { type: DataTypes.ENUM('classroom', 'lab', 'library', 'gym', 'other') }, + capacity: { type: DataTypes.INTEGER }, + floor: { type: DataTypes.INTEGER }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'rooms', + timestamps: true, + underscored: true, +}); + +module.exports = Room; diff --git a/models/School.js b/models/School.js new file mode 100644 index 0000000..63919c5 --- /dev/null +++ b/models/School.js @@ -0,0 +1,83 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * Schools Model - Quản lý thông tin 200 trường + */ +const School = sequelize.define('schools', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + school_code: { + type: DataTypes.STRING(20), + unique: false, + allowNull: false, + comment: 'Mã trường duy nhất', + }, + school_name: { + type: DataTypes.STRING(200), + allowNull: false, + comment: 'Tên trường', + }, + school_type: { + type: DataTypes.ENUM('preschool', 'primary', 'secondary', 'high_school'), + allowNull: false, + comment: 'Loại trường', + }, + address: { + type: DataTypes.TEXT, + comment: 'Địa chỉ trường', + }, + city: { + type: DataTypes.STRING(100), + comment: 'Thành phố', + }, + district: { + type: DataTypes.STRING(100), + comment: 'Quận/Huyện', + }, + phone: { + type: DataTypes.STRING(20), + comment: 'Số điện thoại', + }, + logo: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'URL logo của trường', + }, + config: { + type: DataTypes.JSON, + defaultValue: {}, + comment: 'Cấu hình riêng của trường (JSON)', + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: 'Trạng thái hoạt động', + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'schools', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['school_code'], unique: true }, + { fields: ['school_type'] }, + { fields: ['is_active'] }, + { fields: ['city', 'district'] }, + ], + comment: 'Bảng quản lý thông tin 200 trường học', +}); + +module.exports = School; diff --git a/models/StaffAchievement.js b/models/StaffAchievement.js new file mode 100644 index 0000000..d6c5d5f --- /dev/null +++ b/models/StaffAchievement.js @@ -0,0 +1,92 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * StaffAchievement Model - Lưu trữ chứng chỉ và thành tích đào tạo + */ +const StaffAchievement = sequelize.define('staff_achievements', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + staff_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users_auth', + key: 'id', + }, + comment: 'Nhân viên', + }, + course_id: { + type: DataTypes.UUID, + references: { + model: 'subjects', + key: 'id', + }, + comment: 'Khóa học trong hệ thống', + }, + course_name: { + type: DataTypes.STRING(200), + allowNull: false, + comment: 'Tên khóa học', + }, + completion_date: { + type: DataTypes.DATEONLY, + allowNull: false, + defaultValue: DataTypes.NOW, + comment: 'Ngày hoàn thành', + }, + certificate_url: { + type: DataTypes.STRING(500), + comment: 'URL file chứng chỉ', + }, + certificate_code: { + type: DataTypes.STRING(100), + comment: 'Mã chứng chỉ', + }, + type: { + type: DataTypes.ENUM('mandatory', 'self_study', 'external'), + allowNull: false, + comment: 'Loại: bắt buộc, tự học, bên ngoài', + }, + score: { + type: DataTypes.DECIMAL(5, 2), + comment: 'Điểm số (0-100)', + }, + total_hours: { + type: DataTypes.INTEGER, + comment: 'Tổng số giờ học', + }, + verified_by: { + type: DataTypes.UUID, + references: { + model: 'users_auth', + key: 'id', + }, + comment: 'Admin xác nhận', + }, + verified_at: { + type: DataTypes.DATE, + comment: 'Thời gian xác nhận', + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'staff_achievements', + timestamps: false, + underscored: true, + indexes: [ + { fields: ['staff_id'] }, + { fields: ['type'] }, + { fields: ['completion_date'] }, + { fields: ['certificate_code'] }, + ], + comment: 'Bảng lưu trữ chứng chỉ và thành tích đào tạo', +}); + +module.exports = StaffAchievement; diff --git a/models/StaffContract.js b/models/StaffContract.js new file mode 100644 index 0000000..caff0e4 --- /dev/null +++ b/models/StaffContract.js @@ -0,0 +1,21 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const StaffContract = sequelize.define('staff_contracts', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + teacher_id: { type: DataTypes.UUID, allowNull: false }, + school_id: { type: DataTypes.UUID, allowNull: false }, + contract_type: { type: DataTypes.ENUM('full_time', 'part_time', 'contract'), allowNull: false }, + start_date: { type: DataTypes.DATE, allowNull: false }, + end_date: { type: DataTypes.DATE }, + salary: { type: DataTypes.DECIMAL(12, 2) }, + status: { type: DataTypes.ENUM('active', 'expired', 'terminated'), defaultValue: 'active' }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'staff_contracts', + timestamps: true, + underscored: true, +}); + +module.exports = StaffContract; diff --git a/models/StaffTrainingAssignment.js b/models/StaffTrainingAssignment.js new file mode 100644 index 0000000..5e13220 --- /dev/null +++ b/models/StaffTrainingAssignment.js @@ -0,0 +1,90 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * StaffTrainingAssignment Model - Quản lý phân công đào tạo nhân sự + */ +const StaffTrainingAssignment = sequelize.define('staff_training_assignments', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + staff_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users_auth', + key: 'id', + }, + comment: 'Nhân viên được giao', + }, + subject_id: { + type: DataTypes.UUID, + references: { + model: 'subjects', + key: 'id', + }, + comment: 'Khóa học/môn học bắt buộc', + }, + course_name: { + type: DataTypes.STRING(200), + comment: 'Tên khóa học (nếu không có subject_id)', + }, + assigned_by: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users_auth', + key: 'id', + }, + comment: 'Admin giao nhiệm vụ', + }, + assigned_date: { + type: DataTypes.DATEONLY, + allowNull: false, + defaultValue: DataTypes.NOW, + comment: 'Ngày giao', + }, + deadline: { + type: DataTypes.DATEONLY, + comment: 'Hạn hoàn thành', + }, + status: { + type: DataTypes.ENUM('pending', 'in_progress', 'completed', 'overdue'), + defaultValue: 'pending', + comment: 'Trạng thái', + }, + priority: { + type: DataTypes.ENUM('low', 'normal', 'high', 'urgent'), + defaultValue: 'normal', + comment: 'Độ ưu tiên', + }, + notes: { + type: DataTypes.TEXT, + comment: 'Ghi chú', + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'staff_training_assignments', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['staff_id'] }, + { fields: ['status'] }, + { fields: ['deadline'] }, + { fields: ['assigned_by'] }, + ], + comment: 'Bảng quản lý phân công đào tạo nhân sự', +}); + +module.exports = StaffTrainingAssignment; diff --git a/models/StudentDetail.js b/models/StudentDetail.js new file mode 100644 index 0000000..483e17d --- /dev/null +++ b/models/StudentDetail.js @@ -0,0 +1,24 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const StudentDetail = sequelize.define('student_details', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + user_id: { type: DataTypes.UUID, unique: true, allowNull: false, references: { model: 'user_profiles', key: 'user_id' } }, + student_code: { type: DataTypes.STRING(50), unique: true, allowNull: false }, + enrollment_date: { type: DataTypes.DATE }, + current_class_id: { type: DataTypes.UUID, references: { model: 'classes', key: 'id' } }, + status: { type: DataTypes.ENUM('active', 'on_leave', 'graduated', 'dropped'), defaultValue: 'active' }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'student_details', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['user_id'], unique: true }, + { fields: ['student_code'], unique: true }, + { fields: ['current_class_id'] }, + ], +}); + +module.exports = StudentDetail; diff --git a/models/Subject.js b/models/Subject.js new file mode 100644 index 0000000..d594f5d --- /dev/null +++ b/models/Subject.js @@ -0,0 +1,29 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const Subject = sequelize.define('subjects', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + subject_code: { type: DataTypes.STRING(20), unique: true, allowNull: false }, + subject_name: { type: DataTypes.STRING(100), allowNull: false }, + subject_name_en: { type: DataTypes.STRING(100) }, + description: { type: DataTypes.TEXT }, + is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, + is_premium: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Nội dung premium' }, + is_training: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Nội dung đào tạo nhân sự' }, + is_public: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Nội dung tự học công khai' }, + required_role: { type: DataTypes.STRING(50), comment: 'Role yêu cầu' }, + min_subscription_tier: { type: DataTypes.STRING(50), comment: 'Gói tối thiểu: basic, premium, vip' }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'subjects', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['is_premium'] }, + { fields: ['is_training'] }, + { fields: ['is_public'] }, + ], +}); + +module.exports = Subject; diff --git a/models/SubscriptionPlan.js b/models/SubscriptionPlan.js new file mode 100644 index 0000000..c429895 --- /dev/null +++ b/models/SubscriptionPlan.js @@ -0,0 +1,69 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * SubscriptionPlan Model - Định nghĩa các gói thẻ tháng + */ +const SubscriptionPlan = sequelize.define('subscription_plans', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: 'Tên gói: VIP Học sinh, Premium Giáo viên, etc.', + }, + price: { + type: DataTypes.DECIMAL(10, 2), + allowNull: false, + comment: 'Giá tiền (VND)', + }, + duration_days: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 30, + comment: 'Thời hạn gói (ngày)', + }, + target_role: { + type: DataTypes.STRING(50), + allowNull: false, + comment: 'Role được phép mua: student, parent, teacher', + }, + description: { + type: DataTypes.TEXT, + comment: 'Mô tả gói', + }, + features: { + type: DataTypes.JSON, + comment: 'Danh sách tính năng: ["detailed_reports", "priority_support"]', + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: 'Trạng thái kích hoạt', + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'subscription_plans', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['target_role'] }, + { fields: ['is_active'] }, + { fields: ['price'] }, + ], + comment: 'Bảng định nghĩa các gói thẻ tháng', +}); + +module.exports = SubscriptionPlan; diff --git a/models/TeacherDetail.js b/models/TeacherDetail.js new file mode 100644 index 0000000..f9784cb --- /dev/null +++ b/models/TeacherDetail.js @@ -0,0 +1,31 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const TeacherDetail = sequelize.define('teacher_details', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + user_id: { type: DataTypes.UUID, unique: true, allowNull: false, references: { model: 'user_profiles', key: 'user_id' } }, + teacher_code: { type: DataTypes.STRING(50), unique: true, allowNull: false }, + teacher_type: { type: DataTypes.ENUM('foreign', 'assistant', 'homeroom', 'specialist'), allowNull: false }, + qualification: { type: DataTypes.STRING(200) }, + specialization: { type: DataTypes.STRING(200) }, + hire_date: { type: DataTypes.DATE }, + status: { type: DataTypes.ENUM('active', 'on_leave', 'resigned'), defaultValue: 'active' }, + skill_tags: { type: DataTypes.JSON, comment: 'Array of skills: [{"name": "Python", "level": "expert"}]' }, + certifications: { type: DataTypes.JSON, comment: 'Array of certificates' }, + training_hours: { type: DataTypes.INTEGER, defaultValue: 0, comment: 'Tổng giờ đào tạo' }, + last_training_date: { type: DataTypes.DATEONLY, comment: 'Ngày đào tạo gần nhất' }, + training_score_avg: { type: DataTypes.DECIMAL(5, 2), comment: 'Điểm TB các khóa học' }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'teacher_details', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['user_id'], unique: true }, + { fields: ['teacher_code'], unique: true }, + { fields: ['teacher_type'] }, + ], +}); + +module.exports = TeacherDetail; diff --git a/models/UserAssignment.js b/models/UserAssignment.js new file mode 100644 index 0000000..490f13d --- /dev/null +++ b/models/UserAssignment.js @@ -0,0 +1,27 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const UserAssignment = sequelize.define('user_assignments', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + user_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'users_auth', key: 'id' } }, + role_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'roles', key: 'id' } }, + school_id: { type: DataTypes.UUID, references: { model: 'schools', key: 'id' } }, + class_id: { type: DataTypes.UUID, references: { model: 'classes', key: 'id' } }, + is_primary: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Vai trò chính' }, + valid_from: { type: DataTypes.DATE }, + valid_until: { type: DataTypes.DATE }, + is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +}, { + tableName: 'user_assignments', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['user_id', 'role_id', 'school_id'] }, + { fields: ['school_id'] }, + { fields: ['is_active'] }, + ], +}); + +module.exports = UserAssignment; diff --git a/models/UserProfile.js b/models/UserProfile.js new file mode 100644 index 0000000..c14870c --- /dev/null +++ b/models/UserProfile.js @@ -0,0 +1,79 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const UserProfile = sequelize.define('user_profiles', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + user_id: { + type: DataTypes.UUID, + unique: true, + allowNull: false, + references: { + model: 'users_auth', + key: 'id', + }, + }, + full_name: { + type: DataTypes.STRING(100), + allowNull: false, + }, + first_name: { + type: DataTypes.STRING(50), + }, + last_name: { + type: DataTypes.STRING(50), + }, + date_of_birth: { + type: DataTypes.DATE, + }, + gender: { + type: DataTypes.ENUM('male', 'female', 'other'), + }, + phone: { + type: DataTypes.STRING(20), + }, + avatar_url: { + type: DataTypes.STRING(500), + }, + address: { + type: DataTypes.TEXT, + }, + school_id: { + type: DataTypes.UUID, + }, + city: { + type: DataTypes.STRING(100), + }, + district: { + type: DataTypes.STRING(100), + }, + etc: { + type: DataTypes.JSON, + defaultValue: {}, + comment: 'Thông tin bổ sung (JSONB)', + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'user_profiles', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['user_id'], unique: true }, + { fields: ['full_name'] }, + { fields: ['phone'] }, + ], +}); + +module.exports = UserProfile; diff --git a/models/UserSubscription.js b/models/UserSubscription.js new file mode 100644 index 0000000..3ce5c55 --- /dev/null +++ b/models/UserSubscription.js @@ -0,0 +1,87 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * UserSubscription Model - Quản lý subscription của từng user + */ +const UserSubscription = sequelize.define('user_subscriptions', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + user_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users_auth', + key: 'id', + }, + comment: 'User mua gói', + }, + plan_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'subscription_plans', + key: 'id', + }, + comment: 'Gói đã mua', + }, + start_date: { + type: DataTypes.DATEONLY, + allowNull: false, + comment: 'Ngày bắt đầu', + }, + end_date: { + type: DataTypes.DATEONLY, + allowNull: false, + comment: 'Ngày kết thúc', + }, + status: { + type: DataTypes.ENUM('active', 'expired', 'cancelled'), + defaultValue: 'active', + allowNull: false, + comment: 'Trạng thái subscription', + }, + transaction_id: { + type: DataTypes.STRING(100), + comment: 'Mã giao dịch để đối soát', + }, + payment_method: { + type: DataTypes.STRING(50), + comment: 'Phương thức thanh toán: momo, vnpay, banking', + }, + payment_amount: { + type: DataTypes.DECIMAL(10, 2), + comment: 'Số tiền đã thanh toán', + }, + auto_renew: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: 'Tự động gia hạn', + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'user_subscriptions', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['user_id'] }, + { fields: ['status'] }, + { fields: ['end_date'] }, + { fields: ['transaction_id'] }, + ], + comment: 'Bảng quản lý subscription của user', +}); + +module.exports = UserSubscription; diff --git a/models/UsersAuth.js b/models/UsersAuth.js new file mode 100644 index 0000000..185a503 --- /dev/null +++ b/models/UsersAuth.js @@ -0,0 +1,112 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +/** + * UsersAuth Model - Thông tin đăng nhập người dùng + */ +const UsersAuth = sequelize.define('users_auth', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + username: { + type: DataTypes.STRING(50), + unique: true, + allowNull: false, + comment: 'Tên đăng nhập duy nhất', + }, + email: { + type: DataTypes.STRING(100), + unique: true, + allowNull: false, + validate: { + isEmail: true, + }, + comment: 'Email đăng nhập', + }, + password_hash: { + type: DataTypes.STRING(255), + allowNull: false, + comment: 'Mật khẩu đã hash', + }, + salt: { + type: DataTypes.STRING(255), + allowNull: false, + comment: 'Salt cho mật khẩu', + }, + qr_version: { + type: DataTypes.INTEGER, + defaultValue: 1, + comment: 'Phiên bản mã QR cho điểm danh', + }, + qr_secret: { + type: DataTypes.STRING(255), + comment: 'Secret key cho QR code', + }, + current_session_id: { + type: DataTypes.STRING(255), + comment: 'ID phiên đăng nhập hiện tại', + }, + last_login: { + type: DataTypes.DATE, + comment: 'Lần đăng nhập cuối', + }, + last_login_ip: { + type: DataTypes.STRING(50), + comment: 'IP đăng nhập cuối', + }, + login_count: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: 'Số lần đăng nhập', + }, + login_attempts: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: 'Số lần thử đăng nhập', + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: 'Trạng thái tài khoản', + }, + is_locked: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: 'Tài khoản bị khóa', + }, + failed_login_attempts: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: 'Số lần đăng nhập thất bại', + }, + locked_until: { + type: DataTypes.DATE, + comment: 'Khóa tài khoản đến', + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'users_auth', + timestamps: true, + underscored: true, + indexes: [ + { fields: ['username'], unique: true }, + { fields: ['email'], unique: true }, + { fields: ['is_active'] }, + { fields: ['qr_version'] }, + { fields: ['current_session_id'] }, + ], + comment: 'Bảng quản lý thông tin đăng nhập người dùng', +}); + +module.exports = UsersAuth; diff --git a/models/index.js b/models/index.js new file mode 100644 index 0000000..11c493c --- /dev/null +++ b/models/index.js @@ -0,0 +1,271 @@ +const { sequelize } = require('../config/database'); + +/** + * Import all models + */ +// Group 1: System & Authorization +const School = require('./School'); +const UsersAuth = require('./UsersAuth'); +const Role = require('./Role'); +const Permission = require('./Permission'); +const RolePermission = require('./RolePermission'); +const UserAssignment = require('./UserAssignment'); + +// Group 2: User Profiles +const UserProfile = require('./UserProfile'); +const StudentDetail = require('./StudentDetail'); +const TeacherDetail = require('./TeacherDetail'); +const ParentStudentMap = require('./ParentStudentMap'); +const StaffContract = require('./StaffContract'); + +// Group 3: Academic Structure +const AcademicYear = require('./AcademicYear'); +const Subject = require('./Subject'); +const Class = require('./Class'); +const ClassSchedule = require('./ClassSchedule'); +const Room = require('./Room'); + +// Group 3.1: Learning Content (NEW) +const Chapter = require('./Chapter'); +const Lesson = require('./Lesson'); +const Game = require('./Game'); + +// Group 4: Attendance +const AttendanceLog = require('./AttendanceLog'); +const AttendanceDaily = require('./AttendanceDaily'); +const LeaveRequest = require('./LeaveRequest'); + +// Group 5: Gradebook +const GradeCategory = require('./GradeCategory'); +const GradeItem = require('./GradeItem'); +const Grade = require('./Grade'); +const GradeSummary = require('./GradeSummary'); +const GradeHistory = require('./GradeHistory'); + +// Group 6: Notifications +const Notification = require('./Notification'); +const NotificationLog = require('./NotificationLog'); +const Message = require('./Message'); +const AuditLog = require('./AuditLog'); + +// Group 7: Subscription & Training +const SubscriptionPlan = require('./SubscriptionPlan'); +const UserSubscription = require('./UserSubscription'); +const StaffTrainingAssignment = require('./StaffTrainingAssignment'); +const StaffAchievement = require('./StaffAchievement'); +const ParentAssignedTask = require('./ParentAssignedTask'); + +/** + * Define relationships between models + */ +const setupRelationships = () => { + // UsersAuth relationships + UsersAuth.hasOne(UserProfile, { foreignKey: 'user_id', as: 'profile' }); + UsersAuth.hasMany(UserAssignment, { foreignKey: 'user_id', as: 'assignments' }); + + // UserProfile relationships + UserProfile.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'auth' }); + UserProfile.belongsTo(School, { foreignKey: 'school_id', as: 'school' }); + UserProfile.hasOne(StudentDetail, { foreignKey: 'user_id', as: 'studentDetail' }); + UserProfile.hasOne(TeacherDetail, { foreignKey: 'user_id', as: 'teacherDetail' }); + + // School relationships + School.hasMany(Class, { foreignKey: 'school_id', as: 'classes' }); + School.hasMany(Room, { foreignKey: 'school_id', as: 'rooms' }); + School.hasMany(UserAssignment, { foreignKey: 'school_id', as: 'assignments' }); + School.hasMany(AttendanceLog, { foreignKey: 'school_id', as: 'attendanceLogs' }); + + // Role & Permission relationships + Role.belongsToMany(Permission, { + through: RolePermission, + foreignKey: 'role_id', + otherKey: 'permission_id', + as: 'permissions' + }); + Permission.belongsToMany(Role, { + through: RolePermission, + foreignKey: 'permission_id', + otherKey: 'role_id', + as: 'roles' + }); + + // UserAssignment relationships + UserAssignment.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'user' }); + UserAssignment.belongsTo(Role, { foreignKey: 'role_id', as: 'role' }); + UserAssignment.belongsTo(School, { foreignKey: 'school_id', as: 'school' }); + UserAssignment.belongsTo(Class, { foreignKey: 'class_id', as: 'class' }); + + // StudentDetail relationships + StudentDetail.belongsTo(UserProfile, { + foreignKey: 'user_id', + targetKey: 'user_id', + as: 'profile' + }); + StudentDetail.belongsTo(Class, { foreignKey: 'current_class_id', as: 'currentClass' }); + StudentDetail.belongsToMany(UserProfile, { + through: ParentStudentMap, + foreignKey: 'student_id', + otherKey: 'parent_id', + as: 'parents' + }); + + // TeacherDetail relationships + TeacherDetail.belongsTo(UserProfile, { foreignKey: 'user_id', as: 'profile' }); + TeacherDetail.hasMany(StaffContract, { foreignKey: 'teacher_id', as: 'contracts' }); + + // Class relationships + Class.belongsTo(School, { foreignKey: 'school_id', as: 'school' }); + Class.belongsTo(AcademicYear, { foreignKey: 'academic_year_id', as: 'academicYear' }); + Class.belongsTo(TeacherDetail, { foreignKey: 'homeroom_teacher_id', as: 'homeroomTeacher' }); + Class.hasMany(StudentDetail, { foreignKey: 'current_class_id', as: 'students' }); + Class.hasMany(ClassSchedule, { foreignKey: 'class_id', as: 'schedules' }); + Class.hasMany(Grade, { foreignKey: 'class_id', as: 'grades' }); + + // ClassSchedule relationships + ClassSchedule.belongsTo(Class, { foreignKey: 'class_id', as: 'class' }); + ClassSchedule.belongsTo(Subject, { foreignKey: 'subject_id', as: 'subject' }); + ClassSchedule.belongsTo(Room, { foreignKey: 'room_id', as: 'room' }); + ClassSchedule.belongsTo(TeacherDetail, { foreignKey: 'teacher_id', as: 'teacher' }); + + // Learning Content relationships (NEW) + // Subject -> Chapter (1:N) + Subject.hasMany(Chapter, { foreignKey: 'subject_id', as: 'chapters' }); + Chapter.belongsTo(Subject, { foreignKey: 'subject_id', as: 'subject' }); + + // Chapter -> Lesson (1:N) + Chapter.hasMany(Lesson, { foreignKey: 'chapter_id', as: 'lessons' }); + Lesson.belongsTo(Chapter, { foreignKey: 'chapter_id', as: 'chapter' }); + + // Attendance relationships + AttendanceLog.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'user' }); + AttendanceLog.belongsTo(School, { foreignKey: 'school_id', as: 'school' }); + + AttendanceDaily.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'user' }); + AttendanceDaily.belongsTo(School, { foreignKey: 'school_id', as: 'school' }); + AttendanceDaily.belongsTo(Class, { foreignKey: 'class_id', as: 'class' }); + + LeaveRequest.belongsTo(StudentDetail, { foreignKey: 'student_id', as: 'student' }); + LeaveRequest.belongsTo(UserProfile, { foreignKey: 'requested_by', as: 'requester' }); + LeaveRequest.belongsTo(TeacherDetail, { foreignKey: 'approved_by', as: 'approver' }); + + // Grade relationships + GradeCategory.belongsTo(Subject, { foreignKey: 'subject_id', as: 'subject' }); + GradeCategory.hasMany(GradeItem, { foreignKey: 'category_id', as: 'items' }); + + GradeItem.belongsTo(GradeCategory, { foreignKey: 'category_id', as: 'category' }); + GradeItem.belongsTo(Class, { foreignKey: 'class_id', as: 'class' }); + GradeItem.hasMany(Grade, { foreignKey: 'grade_item_id', as: 'grades' }); + + Grade.belongsTo(GradeItem, { foreignKey: 'grade_item_id', as: 'gradeItem' }); + Grade.belongsTo(StudentDetail, { foreignKey: 'student_id', as: 'student' }); + Grade.belongsTo(Class, { foreignKey: 'class_id', as: 'class' }); + Grade.belongsTo(TeacherDetail, { foreignKey: 'graded_by', as: 'gradedBy' }); + Grade.hasMany(GradeHistory, { foreignKey: 'grade_id', as: 'history' }); + + GradeSummary.belongsTo(StudentDetail, { foreignKey: 'student_id', as: 'student' }); + GradeSummary.belongsTo(Subject, { foreignKey: 'subject_id', as: 'subject' }); + GradeSummary.belongsTo(AcademicYear, { foreignKey: 'academic_year_id', as: 'academicYear' }); + + // Notification relationships + Notification.hasMany(NotificationLog, { foreignKey: 'notification_id', as: 'logs' }); + NotificationLog.belongsTo(Notification, { foreignKey: 'notification_id', as: 'notification' }); + NotificationLog.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'user' }); + + Message.belongsTo(UserProfile, { foreignKey: 'sender_id', as: 'sender' }); + Message.belongsTo(UserProfile, { foreignKey: 'recipient_id', as: 'recipient' }); + + // Subscription relationships + SubscriptionPlan.hasMany(UserSubscription, { foreignKey: 'plan_id', as: 'subscriptions' }); + UserSubscription.belongsTo(SubscriptionPlan, { foreignKey: 'plan_id', as: 'plan' }); + UserSubscription.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'user' }); + + // Training relationships + StaffTrainingAssignment.belongsTo(UsersAuth, { foreignKey: 'staff_id', as: 'staff' }); + StaffTrainingAssignment.belongsTo(UsersAuth, { foreignKey: 'assigned_by', as: 'assigner' }); + StaffTrainingAssignment.belongsTo(Subject, { foreignKey: 'subject_id', as: 'subject' }); + + StaffAchievement.belongsTo(UsersAuth, { foreignKey: 'staff_id', as: 'staff' }); + StaffAchievement.belongsTo(Subject, { foreignKey: 'course_id', as: 'course' }); + StaffAchievement.belongsTo(UsersAuth, { foreignKey: 'verified_by', as: 'verifier' }); + + // Parent assigned task relationships + ParentAssignedTask.belongsTo(UsersAuth, { foreignKey: 'parent_id', as: 'parent' }); + ParentAssignedTask.belongsTo(StudentDetail, { foreignKey: 'student_id', as: 'student' }); + ParentAssignedTask.belongsTo(GradeItem, { foreignKey: 'grade_item_id', as: 'gradeItem' }); + + console.log('✅ Model relationships configured'); +}; + +/** + * Sync database (only in development) + */ +const syncDatabase = async (options = {}) => { + try { + await sequelize.sync(options); + console.log('✅ Database synchronized'); + } catch (error) { + console.error('❌ Database sync failed:', error.message); + throw error; + } +}; + +/** + * Export all models and utilities + */ +module.exports = { + sequelize, + setupRelationships, + syncDatabase, + + // Group 1: System & Authorization + School, + UsersAuth, + Role, + Permission, + RolePermission, + UserAssignment, + + // Group 2: User Profiles + UserProfile, + StudentDetail, + TeacherDetail, + ParentStudentMap, + StaffContract, + + // Group 3: Academic Structure + AcademicYear, + Subject, + Class, + ClassSchedule, + Room, + + // Group 3.1: Learning Content (NEW) + Chapter, + Lesson, + Game, + + // Group 4: Attendance + AttendanceLog, + AttendanceDaily, + LeaveRequest, + + // Group 5: Gradebook + GradeCategory, + GradeItem, + Grade, + GradeSummary, + GradeHistory, + + // Group 6: Notifications + Notification, + NotificationLog, + Message, + AuditLog, + + // Group 7: Subscription & Training + SubscriptionPlan, + UserSubscription, + StaffTrainingAssignment, + StaffAchievement, + ParentAssignedTask, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..a50509a --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "sena-school-management-api", + "version": "1.0.0", + "description": "School Management System API for 200 schools with BullMQ and Redis caching", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js", + "test": "jest --coverage", + "lint": "eslint ." + }, + "keywords": [ + "expressjs", + "sequelize", + "redis", + "bullmq", + "school-management" + ], + "author": "Sena Team", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "sequelize": "^6.35.2", + "mysql2": "^3.6.5", + "ioredis": "^5.3.2", + "bullmq": "^5.1.0", + "dotenv": "^16.3.1", + "helmet": "^7.1.0", + "cors": "^2.8.5", + "compression": "^1.7.4", + "morgan": "^1.10.0", + "express-rate-limit": "^7.1.5", + "joi": "^17.11.0", + "bcrypt": "^5.1.1", + "jsonwebtoken": "^9.0.2", + "winston": "^3.11.0", + "express-async-errors": "^3.1.1" + }, + "devDependencies": { + "nodemon": "^3.0.2", + "eslint": "^9.17.0", + "jest": "^29.7.0", + "supertest": "^7.1.3" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } +} diff --git a/public/js/login.js b/public/js/login.js new file mode 100644 index 0000000..d10f676 --- /dev/null +++ b/public/js/login.js @@ -0,0 +1,252 @@ +// API Base URL - thay đổi theo cấu hình của bạn +const API_BASE_URL = '/api/'; + +const loginForm = document.getElementById('loginForm'); +const loginBtn = document.getElementById('loginBtn'); +const registerBtn = document.getElementById('registerBtn'); +const verifyTokenBtn = document.getElementById('verifyTokenBtn'); +const logoutBtn = document.getElementById('logoutBtn'); +const getAllUsersBtn = document.getElementById('getAllUsersBtn'); +const testHealthBtn = document.getElementById('testHealthBtn'); +const responseContainer = document.getElementById('responseContainer'); + +// Load token from localStorage +let authToken = localStorage.getItem('token'); + +// Helper function to display response +function displayResponse(title, data, status) { + const statusClass = status >= 200 && status < 300 ? 'success' : 'error'; + const statusText = status >= 200 && status < 300 ? 'Success' : 'Error'; + + responseContainer.innerHTML = ` +
+
${title}
+ Status: ${status} ${statusText} +
${JSON.stringify(data, null, 2)}
+
+ `; +} + +// Helper function to show loading +function showLoading(btn) { + const originalText = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = originalText + ''; + return originalText; +} + +function hideLoading(btn, originalText) { + btn.disabled = false; + btn.innerHTML = originalText; +} + +// Login form submission +loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + const originalText = showLoading(loginBtn); + + try { + // Call login endpoint + const response = await fetch(`${API_BASE_URL}auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: username, + password: password, + }), + }); + + const data = await response.json(); + displayResponse('Login Response', data, response.status); + + if (response.ok && data.data && data.data.token) { + // Store token + authToken = data.data.token; + localStorage.setItem('token', authToken); + alert('✅ Đăng nhập thành công!'); + } + } catch (error) { + displayResponse('Login Error', { + error: error.message, + note: 'Không thể kết nối đến server. Hãy đảm bảo server đang chạy.' + }, 500); + console.error('Error:', error); + } finally { + hideLoading(loginBtn, originalText); + } +}); + +// Register button +registerBtn.addEventListener('click', async () => { + const username = prompt('Nhập username:'); + if (!username) return; + + const email = prompt('Nhập email:'); + if (!email) return; + + const password = prompt('Nhập password:'); + if (!password) return; + + const full_name = prompt('Nhập họ tên (optional):'); + + const originalText = showLoading(registerBtn); + + try { + const response = await fetch(`${API_BASE_URL}auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + email, + password, + full_name, + }), + }); + + const data = await response.json(); + displayResponse('Register Response', data, response.status); + + if (response.ok) { + alert('✅ Đăng ký thành công! Bạn có thể đăng nhập ngay.'); + } + } catch (error) { + displayResponse('Register Error', { + error: error.message + }, 500); + console.error('Error:', error); + } finally { + hideLoading(registerBtn, originalText); + } +}); + +// Verify token button +verifyTokenBtn.addEventListener('click', async () => { + const originalText = showLoading(verifyTokenBtn); + + try { + if (!authToken) { + displayResponse('Verify Token Error', { + error: 'Chưa có token. Vui lòng đăng nhập trước.' + }, 401); + return; + } + + const response = await fetch(`${API_BASE_URL}auth/verify-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + }); + + const data = await response.json(); + displayResponse('Verify Token Response', data, response.status); + } catch (error) { + displayResponse('Verify Token Error', { + error: error.message + }, 500); + console.error('Error:', error); + } finally { + hideLoading(verifyTokenBtn, originalText); + } +}); + +// Logout button +logoutBtn.addEventListener('click', async () => { + const originalText = showLoading(logoutBtn); + + try { + if (!authToken) { + displayResponse('Logout Error', { + error: 'Chưa có token. Vui lòng đăng nhập trước.' + }, 401); + return; + } + + const response = await fetch(`${API_BASE_URL}auth/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + }); + + const data = await response.json(); + displayResponse('Logout Response', data, response.status); + + if (response.ok) { + authToken = null; + localStorage.removeItem('token'); + alert('✅ Đăng xuất thành công!'); + } + } catch (error) { + displayResponse('Logout Error', { + error: error.message + }, 500); + console.error('Error:', error); + } finally { + hideLoading(logoutBtn, originalText); + } +}); + +// Get all users +getAllUsersBtn.addEventListener('click', async () => { + const originalText = showLoading(getAllUsersBtn); + + try { + const response = await fetch(`${API_BASE_URL}users?page=1&limit=10`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + displayResponse('Get All Users', data, response.status); + } catch (error) { + displayResponse('Error', { + error: error.message, + note: 'Không thể kết nối đến API. Hãy đảm bảo server đang chạy tại ' + API_BASE_URL + }, 500); + console.error('Error:', error); + } finally { + hideLoading(getAllUsersBtn, originalText); + } +}); + +// Test health endpoint +testHealthBtn.addEventListener('click', async () => { + const originalText = showLoading(testHealthBtn); + + try { + const response = await fetch(`/health`, { + method: 'GET', + }); + + const data = await response.json(); + displayResponse('Health Check', data, response.status); + } catch (error) { + displayResponse('Error', { + error: error.message, + note: 'Không thể kết nối đến API. Hãy đảm bảo server đang chạy tại ' + API_BASE_URL + }, 500); + console.error('Error:', error); + } finally { + hideLoading(testHealthBtn, originalText); + } +}); + +// Auto-test health on page load +window.addEventListener('load', () => { + setTimeout(() => { + testHealthBtn.click(); + }, 500); +}); diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..5ab213f --- /dev/null +++ b/public/login.html @@ -0,0 +1,290 @@ + + + + + + Sena DB API - Login Test + + + +
+ + + + +
+

📊 Kết quả

+

Response từ API server

+ +
+
+

Chưa có request nào được gửi

+
+
+
+
+ + + + diff --git a/routes/academicYearRoutes.js b/routes/academicYearRoutes.js new file mode 100644 index 0000000..5fe4c9a --- /dev/null +++ b/routes/academicYearRoutes.js @@ -0,0 +1,33 @@ +const express = require('express'); +const router = express.Router(); +const academicYearController = require('../controllers/academicYearController'); + +/** + * Academic Year Routes + */ + +// GET /api/academic-years - Get all academic years with pagination +router.get('/', academicYearController.getAllAcademicYears); + +// GET /api/academic-years/current - Get current academic year +router.get('/current', academicYearController.getCurrentAcademicYear); + +// GET /api/academic-years/datatypes/schema - Get academic year datatypes +router.get('/datatypes/schema', academicYearController.getAcademicYearDatatypes); + +// GET /api/academic-years/:id - Get academic year by ID +router.get('/:id', academicYearController.getAcademicYearById); + +// POST /api/academic-years - Create new academic year +router.post('/', academicYearController.createAcademicYear); + +// PUT /api/academic-years/:id - Update academic year +router.put('/:id', academicYearController.updateAcademicYear); + +// PUT /api/academic-years/:id/set-current - Set as current academic year +router.put('/:id/set-current', academicYearController.setCurrentAcademicYear); + +// DELETE /api/academic-years/:id - Delete academic year +router.delete('/:id', academicYearController.deleteAcademicYear); + +module.exports = router; diff --git a/routes/attendanceRoutes.js b/routes/attendanceRoutes.js new file mode 100644 index 0000000..aedf22f --- /dev/null +++ b/routes/attendanceRoutes.js @@ -0,0 +1,27 @@ +const express = require('express'); +const router = express.Router(); +const attendanceController = require('../controllers/attendanceController'); + +/** + * Attendance Routes + */ + +// Get attendance logs with pagination +router.get('/logs', attendanceController.getAttendanceLogs); + +// Create attendance log (QR scan) +router.post('/logs', attendanceController.createAttendanceLog); + +// Get daily attendance summary +router.get('/daily', attendanceController.getDailyAttendance); + +// Process daily attendance (aggregate from logs) +router.post('/process', attendanceController.processAttendance); + +// Get attendance statistics +router.get('/stats', attendanceController.getAttendanceStats); + +// Get attendance datatypes +router.get('/datatypes/schema', attendanceController.getAttendanceDatatypes); + +module.exports = router; diff --git a/routes/authRoutes.js b/routes/authRoutes.js new file mode 100644 index 0000000..d1f94a7 --- /dev/null +++ b/routes/authRoutes.js @@ -0,0 +1,24 @@ +const express = require('express'); +const router = express.Router(); +const authController = require('../controllers/authController'); + +/** + * Authentication Routes - /api/auth + */ + +// Login +router.post('/login', authController.login); + +// Register +router.post('/register', authController.register); + +// Verify token +router.post('/verify-token', authController.verifyToken); + +// Logout +router.post('/logout', authController.logout); + +// Get current user +router.get('/me', authController.me); + +module.exports = router; diff --git a/routes/chapterRoutes.js b/routes/chapterRoutes.js new file mode 100644 index 0000000..665547b --- /dev/null +++ b/routes/chapterRoutes.js @@ -0,0 +1,28 @@ +const express = require('express'); +const router = express.Router(); +const chapterController = require('../controllers/chapterController'); + +/** + * Chapter Routes + * Base path: /api/chapters + */ + +// Get all chapters +router.get('/', chapterController.getAllChapters); + +// Get chapter by ID +router.get('/:id', chapterController.getChapterById); + +// Get lessons in a chapter +router.get('/:id/lessons', chapterController.getLessonsByChapter); + +// Create new chapter +router.post('/', chapterController.createChapter); + +// Update chapter +router.put('/:id', chapterController.updateChapter); + +// Delete chapter +router.delete('/:id', chapterController.deleteChapter); + +module.exports = router; diff --git a/routes/classRoutes.js b/routes/classRoutes.js new file mode 100644 index 0000000..5aeebcb --- /dev/null +++ b/routes/classRoutes.js @@ -0,0 +1,33 @@ +const express = require('express'); +const router = express.Router(); +const classController = require('../controllers/classController'); + +/** + * Class Routes + */ + +// GET /api/classes - Get all classes with pagination +router.get('/', classController.getAllClasses); + +// GET /api/classes/statistics - Get class statistics +router.get('/statistics', classController.getClassStatistics); + +// GET /api/classes/datatypes/schema - Get class datatypes +router.get('/datatypes/schema', classController.getClassDatatypes); + +// GET /api/classes/:id - Get class by ID +router.get('/:id', classController.getClassById); + +// GET /api/classes/:id/students - Get students in a class +router.get('/:id/students', classController.getStudentsByClass); + +// POST /api/classes - Create new class +router.post('/', classController.createClass); + +// PUT /api/classes/:id - Update class +router.put('/:id', classController.updateClass); + +// DELETE /api/classes/:id - Delete class +router.delete('/:id', classController.deleteClass); + +module.exports = router; diff --git a/routes/gameRoutes.js b/routes/gameRoutes.js new file mode 100644 index 0000000..ceba2fe --- /dev/null +++ b/routes/gameRoutes.js @@ -0,0 +1,34 @@ +const express = require('express'); +const router = express.Router(); +const gameController = require('../controllers/gameController'); + +/** + * Game Routes + * Base path: /api/games + */ + +// Get all games +router.get('/', gameController.getAllGames); + +// Get game statistics +router.get('/stats', gameController.getGameStats); + +// Get games by type +router.get('/type/:type', gameController.getGamesByType); + +// Get game by ID +router.get('/:id', gameController.getGameById); + +// Create new game +router.post('/', gameController.createGame); + +// Update game +router.put('/:id', gameController.updateGame); + +// Delete game +router.delete('/:id', gameController.deleteGame); + +// Increment play count +router.post('/:id/play', gameController.incrementPlayCount); + +module.exports = router; diff --git a/routes/gradeRoutes.js b/routes/gradeRoutes.js new file mode 100644 index 0000000..1513f68 --- /dev/null +++ b/routes/gradeRoutes.js @@ -0,0 +1,33 @@ +const express = require('express'); +const router = express.Router(); +const gradeController = require('../controllers/gradeController'); + +/** + * Grade Routes + */ + +// Get grade datatypes (must be before /:id route) +router.get('/datatypes/schema', gradeController.getGradeDatatypes); + +// Get student grade summary (must be before /:id route) +router.get('/summary/:student_id/:academic_year_id', gradeController.getStudentGradeSummary); + +// Calculate student GPA +router.post('/calculate-gpa', gradeController.calculateGPA); + +// Get all grades with pagination +router.get('/', gradeController.getAllGrades); + +// Get grade by ID +router.get('/:id', gradeController.getGradeById); + +// Create new grade +router.post('/', gradeController.createGrade); + +// Update grade +router.put('/:id', gradeController.updateGrade); + +// Delete grade +router.delete('/:id', gradeController.deleteGrade); + +module.exports = router; diff --git a/routes/parentTaskRoutes.js b/routes/parentTaskRoutes.js new file mode 100644 index 0000000..9c1c1e7 --- /dev/null +++ b/routes/parentTaskRoutes.js @@ -0,0 +1,30 @@ +const express = require('express'); +const router = express.Router(); +const parentTaskController = require('../controllers/parentTaskController'); + +/** + * Parent Task Routes + */ + +// Get task stats +router.get('/stats', parentTaskController.getTaskStats); + +// Get parent's assigned tasks +router.get('/parent/:parent_id', parentTaskController.getParentTasks); + +// Get student's tasks +router.get('/student/:student_id', parentTaskController.getStudentTasks); + +// Parent assigns task to student +router.post('/assign', parentTaskController.assignTask); + +// Complete task +router.put('/:id/complete', parentTaskController.completeTask); + +// Update task status +router.put('/:id/status', parentTaskController.updateTaskStatus); + +// Delete task +router.delete('/:id', parentTaskController.deleteTask); + +module.exports = router; diff --git a/routes/roomRoutes.js b/routes/roomRoutes.js new file mode 100644 index 0000000..4395298 --- /dev/null +++ b/routes/roomRoutes.js @@ -0,0 +1,30 @@ +const express = require('express'); +const router = express.Router(); +const roomController = require('../controllers/roomController'); + +/** + * Room Routes + */ + +// Get room datatypes (must be before /:id route) +router.get('/datatypes/schema', roomController.getRoomDatatypes); + +// Get rooms by school (must be before /:id route) +router.get('/school/:school_id', roomController.getRoomsBySchool); + +// Get all rooms with pagination +router.get('/', roomController.getAllRooms); + +// Get room by ID +router.get('/:id', roomController.getRoomById); + +// Create new room +router.post('/', roomController.createRoom); + +// Update room +router.put('/:id', roomController.updateRoom); + +// Delete room +router.delete('/:id', roomController.deleteRoom); + +module.exports = router; diff --git a/routes/schoolRoutes.js b/routes/schoolRoutes.js new file mode 100644 index 0000000..ca3c3e4 --- /dev/null +++ b/routes/schoolRoutes.js @@ -0,0 +1,73 @@ +const express = require('express'); +const router = express.Router(); +const schoolController = require('../controllers/schoolController'); +// const { authenticate, authorize } = require('../middleware/auth'); // Auth middleware + +/** + * School Routes + * Base path: /api/schools + */ + +/** + * @route GET /api/schools/datatypes/schema + * @desc Get school data types + * @access Protected + */ +router.get('/datatypes/schema', schoolController.getSchoolDatatypes); + +/** + * @route GET /api/schools + * @desc Get all schools with pagination + * @query page, limit, type, city, is_active + * @access Public (or Protected based on requirements) + */ +router.get('/', schoolController.getAllSchools); + +/** + * @route GET /api/schools/:id + * @desc Get school by ID + * @access Public + */ +router.get('/:id', schoolController.getSchoolById); + +/** + * @route GET /api/schools/:id/stats + * @desc Get school statistics + * @access Protected + */ +router.get('/:id/stats', schoolController.getSchoolStats); + +/** + * @route POST /api/schools + * @desc Create new school + * @access Protected - Admin only + */ +router.post('/', + // authenticate, + // authorize(['system_admin', 'center_manager']), + schoolController.createSchool +); + +/** + * @route PUT /api/schools/:id + * @desc Update school + * @access Protected - Admin only + */ +router.put('/:id', + // authenticate, + // authorize(['system_admin', 'center_manager']), + schoolController.updateSchool +); + +/** + * @route DELETE /api/schools/:id + * @desc Delete (soft delete) school + * @access Protected - System Admin only + */ +router.delete('/:id', + // authenticate, + // authorize(['system_admin']), + schoolController.deleteSchool +); + +module.exports = router; diff --git a/routes/studentRoutes.js b/routes/studentRoutes.js new file mode 100644 index 0000000..b8baaf7 --- /dev/null +++ b/routes/studentRoutes.js @@ -0,0 +1,27 @@ +const express = require('express'); +const router = express.Router(); +const studentController = require('../controllers/studentController'); + +/** + * Student Routes + */ + +// Get student datatypes (must be before /:id route) +router.get('/datatypes/schema', studentController.getStudentDatatypes); + +// Get all students with pagination +router.get('/', studentController.getAllStudents); + +// Get student by ID +router.get('/:id', studentController.getStudentById); + +// Create new student +router.post('/', studentController.createStudent); + +// Update student +router.put('/:id', studentController.updateStudent); + +// Delete student (update status to dropped) +router.delete('/:id', studentController.deleteStudent); + +module.exports = router; diff --git a/routes/subjectRoutes.js b/routes/subjectRoutes.js new file mode 100644 index 0000000..0f27a23 --- /dev/null +++ b/routes/subjectRoutes.js @@ -0,0 +1,36 @@ +const express = require('express'); +const router = express.Router(); +const subjectController = require('../controllers/subjectController'); + +/** + * Subject Routes + */ + +// GET /api/subjects - Get all subjects with pagination +router.get('/', subjectController.getAllSubjects); + +// GET /api/subjects/active - Get all active subjects +router.get('/active', subjectController.getActiveSubjects); + +// GET /api/subjects/datatypes/schema - Get subject datatypes +router.get('/datatypes/schema', subjectController.getSubjectDatatypes); + +// GET /api/subjects/code/:code - Get subject by code +router.get('/code/:code', subjectController.getSubjectByCode); + +// GET /api/subjects/:id - Get subject by ID +router.get('/:id', subjectController.getSubjectById); + +// GET /api/subjects/:id/chapters - Get chapters by subject +router.get('/:id/chapters', subjectController.getChaptersBySubject); + +// POST /api/subjects - Create new subject +router.post('/', subjectController.createSubject); + +// PUT /api/subjects/:id - Update subject +router.put('/:id', subjectController.updateSubject); + +// DELETE /api/subjects/:id - Delete subject +router.delete('/:id', subjectController.deleteSubject); + +module.exports = router; diff --git a/routes/subscriptionRoutes.js b/routes/subscriptionRoutes.js new file mode 100644 index 0000000..778a83e --- /dev/null +++ b/routes/subscriptionRoutes.js @@ -0,0 +1,24 @@ +const express = require('express'); +const router = express.Router(); +const subscriptionController = require('../controllers/subscriptionController'); + +/** + * Subscription Routes + */ + +// Get all subscription plans +router.get('/plans', subscriptionController.getPlans); + +// Get subscription stats +router.get('/stats', subscriptionController.getSubscriptionStats); + +// Get user subscription status +router.get('/user/:user_id', subscriptionController.getUserSubscription); + +// Purchase subscription +router.post('/purchase', subscriptionController.purchaseSubscription); + +// Cancel subscription +router.post('/:id/cancel', subscriptionController.cancelSubscription); + +module.exports = router; diff --git a/routes/teacherRoutes.js b/routes/teacherRoutes.js new file mode 100644 index 0000000..c1e13d3 --- /dev/null +++ b/routes/teacherRoutes.js @@ -0,0 +1,30 @@ +const express = require('express'); +const router = express.Router(); +const teacherController = require('../controllers/teacherController'); + +/** + * Teacher Routes + */ + +// Get teacher datatypes (must be before /:id route) +router.get('/datatypes/schema', teacherController.getTeacherDatatypes); + +// Get all teachers with pagination +router.get('/', teacherController.getAllTeachers); + +// Get teacher by ID +router.get('/:id', teacherController.getTeacherById); + +// Create new teacher +router.post('/', teacherController.createTeacher); + +// Update teacher +router.put('/:id', teacherController.updateTeacher); + +// Update teacher profile (separate endpoint for user self-update) +router.put('/:id/profile', teacherController.updateTeacherProfile); + +// Delete teacher (update status to resigned) +router.delete('/:id', teacherController.deleteTeacher); + +module.exports = router; diff --git a/routes/trainingRoutes.js b/routes/trainingRoutes.js new file mode 100644 index 0000000..81c608d --- /dev/null +++ b/routes/trainingRoutes.js @@ -0,0 +1,27 @@ +const express = require('express'); +const router = express.Router(); +const trainingController = require('../controllers/trainingController'); + +/** + * Training Routes + */ + +// Get training stats +router.get('/stats', trainingController.getTrainingStats); + +// Get staff training assignments +router.get('/assignments/:staff_id', trainingController.getStaffAssignments); + +// Assign training to staff +router.post('/assignments', trainingController.assignTraining); + +// Update assignment status +router.put('/assignments/:id/status', trainingController.updateAssignmentStatus); + +// Get staff achievements/certificates +router.get('/achievements/:staff_id', trainingController.getStaffAchievements); + +// Create achievement record +router.post('/achievements', trainingController.createAchievement); + +module.exports = router; diff --git a/routes/userRoutes.js b/routes/userRoutes.js new file mode 100644 index 0000000..ac9ba21 --- /dev/null +++ b/routes/userRoutes.js @@ -0,0 +1,41 @@ +const express = require('express'); +const router = express.Router(); +const userController = require('../controllers/userController'); + +/** + * User Routes + */ + +// === Authentication Routes === +// Login +router.post('/login', userController.login); + +// Register +router.post('/register', userController.register); + +// Verify token +router.post('/verify-token', userController.verifyToken); + +// Logout +router.post('/logout', userController.logout); + +// === User Management Routes === +// Get user datatypes (must be before /:id route) +router.get('/datatypes/schema', userController.getUserDatatypes); + +// Get all users with pagination +router.get('/', userController.getAllUsers); + +// Get user by ID +router.get('/:id', userController.getUserById); + +// Create new user +router.post('/', userController.createUser); + +// Update user +router.put('/:id', userController.updateUser); + +// Delete user (soft delete) +router.delete('/:id', userController.deleteUser); + +module.exports = router; diff --git a/scripts/add-senaai-center.js b/scripts/add-senaai-center.js new file mode 100644 index 0000000..d198c33 --- /dev/null +++ b/scripts/add-senaai-center.js @@ -0,0 +1,79 @@ +const { sequelize } = require('../config/database'); +const School = require('../models/School'); + +/** + * Script thêm trung tâm SenaAI vào database + * Chạy: node src/scripts/add-senaai-center.js + */ + +async function addSenaAICenter() { + try { + console.log('🚀 Đang thêm trung tâm SenaAI...\n'); + + // Kết nối database + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công\n'); + + // Kiểm tra trung tâm đã tồn tại chưa + const existingCenter = await School.findOne({ + where: { school_code: 'SENAAI' } + }); + + if (existingCenter) { + console.log('⚠️ Trung tâm SenaAI đã tồn tại!'); + console.log(` Tên: ${existingCenter.school_name}`); + console.log(` Mã: ${existingCenter.school_code}`); + console.log(` Địa chỉ: ${existingCenter.address || 'N/A'}`); + console.log('\n'); + } else { + // Tạo mới trung tâm SenaAI + const senaAI = await School.create({ + school_code: 'SENAAI', + school_name: 'Trung tâm Giáo dục SenaAI', + school_type: 'primary', + address: 'Thành phố Hồ Chí Minh', + city: 'Thành phố Hồ Chí Minh', + district: 'Đang cập nhật', + phone: '', + config: { + type: 'education_center', + services: ['AI Education', 'Technology Training', 'School Management'] + }, + is_active: true, + }); + + console.log('✅ Đã tạo trung tâm SenaAI thành công!'); + console.log(` ID: ${senaAI.id}`); + console.log(` Tên: ${senaAI.school_name}`); + console.log(` Mã: ${senaAI.school_code}`); + console.log('\n'); + } + + // Hiển thị tổng số trường + const totalSchools = await School.count(); + console.log(`📊 Tổng số trường trong hệ thống: ${totalSchools}`); + console.log('\n✅ Hoàn thành!\n'); + + } catch (error) { + console.error('❌ Lỗi:', error); + throw error; + } finally { + await sequelize.close(); + console.log('🔌 Đã đóng kết nối database'); + } +} + +// Chạy script +if (require.main === module) { + addSenaAICenter() + .then(() => { + console.log('\n🎉 Script hoàn thành thành công!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 Script thất bại:', error); + process.exit(1); + }); +} + +module.exports = { addSenaAICenter }; diff --git a/scripts/clear-teacher-cache.js b/scripts/clear-teacher-cache.js new file mode 100644 index 0000000..fe74354 --- /dev/null +++ b/scripts/clear-teacher-cache.js @@ -0,0 +1,50 @@ +/** + * Script: Clear Teacher Cache + * Xóa tất cả cache liên quan đến teachers + */ + +require('dotenv').config(); +const { redisClient } = require('../config/redis'); + +async function clearTeacherCache() { + console.log('🧹 Clearing teacher cache...\n'); + + try { + // Redis client is already connected via config + console.log('✅ Using existing Redis connection\n'); + + // Delete all teacher-related cache patterns + const patterns = [ + 'teachers:list:*', + 'teacher:*', + ]; + + let totalDeleted = 0; + + for (const pattern of patterns) { + console.log(`Searching for: ${pattern}`); + const keys = await redisClient.keys(pattern); + + if (keys.length > 0) { + console.log(` Found ${keys.length} keys`); + await redisClient.del(...keys); + totalDeleted += keys.length; + console.log(` ✅ Deleted ${keys.length} keys\n`); + } else { + console.log(` No keys found\n`); + } + } + + console.log('='.repeat(60)); + console.log(`✅ Cache cleared! Total deleted: ${totalDeleted} keys`); + console.log('='.repeat(60)); + + process.exit(0); + + } catch (error) { + console.error('❌ Error:', error.message); + process.exit(1); + } +} + +clearTeacherCache(); diff --git a/scripts/create-test-user.js b/scripts/create-test-user.js new file mode 100644 index 0000000..182c071 --- /dev/null +++ b/scripts/create-test-user.js @@ -0,0 +1,111 @@ +const { UsersAuth, UserProfile } = require('../models'); +const { initializeDatabase } = require('../config/database'); +const { setupRelationships } = require('../models'); +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); + +/** + * Script tạo user test để thử nghiệm login + */ +async function createTestUser() { + try { + console.log('🚀 Bắt đầu tạo user test...\n'); + + // Khởi tạo database + await initializeDatabase(); + setupRelationships(); + + // Test credentials + const testUsers = [ + { + username: 'admin', + email: 'admin@senaai.tech', + password: 'admin123', + full_name: 'Administrator', + phone: '0900000001', + }, + { + username: 'teacher1', + email: 'teacher1@senaai.tech', + password: 'teacher123', + full_name: 'Giáo viên Test', + phone: '0900000002', + }, + { + username: 'student1', + email: 'student1@senaai.tech', + password: 'student123', + full_name: 'Học sinh Test', + phone: '0900000003', + }, + ]; + + for (const userData of testUsers) { + console.log(`📝 Đang tạo user: ${userData.username}...`); + + // Kiểm tra user đã tồn tại chưa + const existingUser = await UsersAuth.findOne({ + where: { + [require('sequelize').Op.or]: [ + { username: userData.username }, + { email: userData.email }, + ], + }, + }); + + if (existingUser) { + console.log(` ⚠️ User ${userData.username} đã tồn tại, bỏ qua.\n`); + continue; + } + + // Hash password + const salt = crypto.randomBytes(16).toString('hex'); + const passwordHash = await bcrypt.hash(userData.password + salt, 10); + + // Tạo user + const newUser = await UsersAuth.create({ + username: userData.username, + email: userData.email, + password_hash: passwordHash, + salt, + qr_secret: crypto.randomBytes(32).toString('hex'), + is_active: true, + }); + + // Tạo profile + await UserProfile.create({ + user_id: newUser.id, + full_name: userData.full_name, + phone: userData.phone, + }); + + console.log(` ✅ Đã tạo user: ${userData.username}`); + console.log(` Email: ${userData.email}`); + console.log(` Password: ${userData.password}\n`); + } + + console.log('✅ Hoàn tất tạo test users!\n'); + console.log('═══════════════════════════════════════════════════'); + console.log('📋 THÔNG TIN ĐĂNG NHẬP TEST:'); + console.log('═══════════════════════════════════════════════════'); + testUsers.forEach(user => { + console.log(`\n👤 ${user.full_name}`); + console.log(` Username: ${user.username}`); + console.log(` Email: ${user.email}`); + console.log(` Password: ${user.password}`); + }); + console.log('\n═══════════════════════════════════════════════════'); + console.log('\n🌐 Mở trình duyệt và truy cập:'); + console.log(' http://localhost:4000/login.html'); + console.log('\n💡 Sử dụng thông tin trên để đăng nhập!\n'); + + process.exit(0); + } catch (error) { + console.error('❌ Lỗi:', error.message); + console.error(error); + process.exit(1); + } +} + +// Run script +createTestUser(); diff --git a/scripts/drop-grade-tables.js b/scripts/drop-grade-tables.js new file mode 100644 index 0000000..6cbf5a4 --- /dev/null +++ b/scripts/drop-grade-tables.js @@ -0,0 +1,22 @@ +const { sequelize } = require('../config/database'); + +async function dropTables() { + try { + console.log('🔄 Dropping old grade tables...'); + + await sequelize.query('DROP TABLE IF EXISTS grade_history'); + await sequelize.query('DROP TABLE IF EXISTS grade_summaries'); + await sequelize.query('DROP TABLE IF EXISTS grades'); + await sequelize.query('DROP TABLE IF EXISTS grade_items'); + await sequelize.query('DROP TABLE IF EXISTS grade_categories'); + await sequelize.query('DROP TABLE IF EXISTS leave_requests'); + + console.log('✅ Tables dropped successfully'); + process.exit(0); + } catch (error) { + console.error('❌ Error dropping tables:', error.message); + process.exit(1); + } +} + +dropTables(); diff --git a/scripts/dump-and-sync-teachers.js b/scripts/dump-and-sync-teachers.js new file mode 100644 index 0000000..14ae41d --- /dev/null +++ b/scripts/dump-and-sync-teachers.js @@ -0,0 +1,267 @@ +/** + * Script: Dump và Sync All Teachers + * Mục đích: Kiểm tra và tạo user_profile cho TẤT CẢ teachers trong hệ thống + * + * Usage: + * node src/scripts/dump-and-sync-teachers.js + * node src/scripts/dump-and-sync-teachers.js --auto-fix + */ + +require('dotenv').config(); +const { sequelize, TeacherDetail, UserProfile, UsersAuth } = require('../models'); +const teacherProfileService = require('../services/teacherProfileService'); + +async function dumpAllTeachers() { + console.log('📊 Dumping All Teachers Information...\n'); + console.log('='.repeat(80)); + + try { + // Get all teachers (without join to avoid association issues) + const teachers = await TeacherDetail.findAll({ + order: [['created_at', 'ASC']], + }); + + console.log(`\n📈 Total Teachers: ${teachers.length}\n`); + + const withProfile = []; + const withoutProfile = []; + const invalidUserId = []; + + for (const teacher of teachers) { + const teacherInfo = { + id: teacher.id, + user_id: teacher.user_id, + teacher_code: teacher.teacher_code, + teacher_type: teacher.teacher_type, + status: teacher.status, + created_at: teacher.created_at, + }; + + // Check if user_id exists in users_auth + const userAuth = await UsersAuth.findByPk(teacher.user_id, { + attributes: ['id', 'username', 'email', 'is_active'], + }); + + if (!userAuth) { + invalidUserId.push({ + ...teacherInfo, + issue: 'user_id not found in users_auth', + }); + continue; + } + + teacherInfo.username = userAuth.username; + teacherInfo.email = userAuth.email; + teacherInfo.is_active = userAuth.is_active; + + // Check if profile exists + const userProfile = await UserProfile.findOne({ + where: { user_id: teacher.user_id }, + }); + + if (userProfile) { + teacherInfo.profile = { + id: userProfile.id, + full_name: userProfile.full_name, + phone: userProfile.phone, + needs_update: userProfile.etc?.needs_profile_update, + }; + withProfile.push(teacherInfo); + } else { + withoutProfile.push(teacherInfo); + } + } + + // Display results + console.log('✅ Teachers WITH Profile:', withProfile.length); + if (withProfile.length > 0) { + console.log('\nSample (first 5):'); + withProfile.slice(0, 5).forEach((t, i) => { + console.log(` ${i + 1}. ${t.teacher_code} (${t.teacher_type}) - ${t.username}`); + console.log(` Profile: ${t.profile.full_name} | Phone: ${t.profile.phone || 'N/A'}`); + }); + if (withProfile.length > 5) { + console.log(` ... and ${withProfile.length - 5} more\n`); + } + } + + console.log('\n❌ Teachers WITHOUT Profile:', withoutProfile.length); + if (withoutProfile.length > 0) { + console.log('\nList:'); + withoutProfile.forEach((t, i) => { + console.log(` ${i + 1}. ${t.teacher_code} (${t.teacher_type}) - ${t.username} - user_id: ${t.user_id}`); + }); + } + + console.log('\n⚠️ Teachers with Invalid user_id:', invalidUserId.length); + if (invalidUserId.length > 0) { + console.log('\nList:'); + invalidUserId.forEach((t, i) => { + console.log(` ${i + 1}. ${t.teacher_code} - ${t.issue} - user_id: ${t.user_id}`); + }); + } + + console.log('\n' + '='.repeat(80)); + + return { + total: teachers.length, + withProfile: withProfile.length, + withoutProfile: withoutProfile.length, + invalidUserId: invalidUserId.length, + teachers: { + withProfile, + withoutProfile, + invalidUserId, + } + }; + + } catch (error) { + console.error('❌ Dump failed:', error.message); + throw error; + } +} + +async function autoFixProfiles(teachersData) { + console.log('\n🔧 Starting Auto-Fix for Teachers without Profile...\n'); + + const { withoutProfile } = teachersData.teachers; + + if (withoutProfile.length === 0) { + console.log('✅ No teachers need fixing. All have profiles!\n'); + return { success: 0, failed: 0 }; + } + + console.log(`📋 Processing ${withoutProfile.length} teachers...\n`); + + const results = { + success: [], + failed: [], + }; + + for (const teacher of withoutProfile) { + try { + console.log(`Processing: ${teacher.teacher_code} (${teacher.username})...`); + + // Create profile using service + const userProfile = await UserProfile.create({ + user_id: teacher.user_id, + full_name: teacherProfileService._generateFullNameFromCode(teacher.teacher_code), + first_name: '', + last_name: '', + phone: '', + date_of_birth: null, + gender: null, + address: '', + school_id: null, + city: '', + district: '', + avatar_url: null, + etc: { + teacher_code: teacher.teacher_code, + teacher_type: teacher.teacher_type, + username: teacher.username, + synced_from: 'dump-and-sync-script', + needs_profile_update: true, + synced_at: new Date().toISOString(), + } + }); + + results.success.push({ + teacher_code: teacher.teacher_code, + username: teacher.username, + user_id: teacher.user_id, + profile_id: userProfile.id, + full_name: userProfile.full_name, + }); + + console.log(` ✅ Created profile: ${userProfile.full_name}\n`); + + } catch (error) { + results.failed.push({ + teacher_code: teacher.teacher_code, + username: teacher.username, + error: error.message, + }); + + console.log(` ❌ Failed: ${error.message}\n`); + } + } + + // Summary + console.log('\n' + '='.repeat(80)); + console.log('📊 AUTO-FIX SUMMARY'); + console.log('='.repeat(80)); + console.log(`✅ Success: ${results.success.length}`); + console.log(`❌ Failed: ${results.failed.length}`); + + if (results.success.length > 0) { + console.log('\n✅ Successfully Created Profiles:'); + results.success.forEach((item, i) => { + console.log(` ${i + 1}. ${item.teacher_code} → ${item.full_name}`); + console.log(` user_id: ${item.user_id} | profile_id: ${item.profile_id}`); + }); + } + + if (results.failed.length > 0) { + console.log('\n❌ Failed:'); + results.failed.forEach((item, i) => { + console.log(` ${i + 1}. ${item.teacher_code} - ${item.error}`); + }); + } + + console.log('\n' + '='.repeat(80)); + + return { + success: results.success.length, + failed: results.failed.length, + details: results, + }; +} + +async function main() { + console.log('\n🚀 Teacher Profile Dump & Sync Tool\n'); + + try { + // Parse arguments + const args = process.argv.slice(2); + const autoFix = args.includes('--auto-fix'); + + // Connect to database + await sequelize.authenticate(); + console.log('✅ Database connected\n'); + + // Step 1: Dump all teachers + const dumpResults = await dumpAllTeachers(); + + // Step 2: Ask for auto-fix if not specified + if (!autoFix && dumpResults.withoutProfile > 0) { + console.log('\n⚠️ Found teachers without profile!'); + console.log('💡 Run with --auto-fix to create profiles automatically:'); + console.log(' node src/scripts/dump-and-sync-teachers.js --auto-fix\n'); + process.exit(0); + } + + // Step 3: Auto-fix if requested + if (autoFix && dumpResults.withoutProfile > 0) { + const fixResults = await autoFixProfiles(dumpResults); + + console.log('\n🎉 COMPLETED!'); + console.log(` Total: ${dumpResults.total} teachers`); + console.log(` Fixed: ${fixResults.success} profiles`); + console.log(` Already had: ${dumpResults.withProfile} profiles`); + console.log(` Failed: ${fixResults.failed} profiles\n`); + } else if (autoFix) { + console.log('\n✅ All teachers already have profiles! Nothing to fix.\n'); + } + + process.exit(0); + + } catch (error) { + console.error('\n❌ Error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +// Run +main(); diff --git a/scripts/export-teachers-info.js b/scripts/export-teachers-info.js new file mode 100644 index 0000000..8b6584f --- /dev/null +++ b/scripts/export-teachers-info.js @@ -0,0 +1,193 @@ +/** + * Script: Export Teacher Profiles to JSON + * Xuất chi tiết thông tin tất cả teachers ra file JSON + * + * Usage: + * node src/scripts/export-teachers-info.js + * node src/scripts/export-teachers-info.js --output=teachers.json + */ + +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const { sequelize, TeacherDetail, UserProfile, UsersAuth } = require('../models'); + +async function exportTeachersInfo() { + console.log('📤 Exporting All Teachers Information...\n'); + + try { + await sequelize.authenticate(); + console.log('✅ Database connected\n'); + + // Get all teachers + const teachers = await TeacherDetail.findAll({ + order: [['created_at', 'ASC']], + }); + + console.log(`📊 Found ${teachers.length} teachers\n`); + console.log('Processing...\n'); + + const exportData = []; + + for (const teacher of teachers) { + // Get auth info + const userAuth = await UsersAuth.findByPk(teacher.user_id, { + attributes: ['id', 'username', 'email', 'is_active', 'last_login', 'login_count', 'qr_version'], + }); + + // Get profile info + const userProfile = await UserProfile.findOne({ + where: { user_id: teacher.user_id }, + }); + + const teacherData = { + // Teacher Details + teacher_id: teacher.id, + teacher_code: teacher.teacher_code, + teacher_type: teacher.teacher_type, + status: teacher.status, + qualification: teacher.qualification, + specialization: teacher.specialization, + hire_date: teacher.hire_date, + training_hours: teacher.training_hours, + last_training_date: teacher.last_training_date, + training_score_avg: teacher.training_score_avg, + skill_tags: teacher.skill_tags, + certifications: teacher.certifications, + created_at: teacher.created_at, + updated_at: teacher.updated_at, + + // Auth Info + user_id: teacher.user_id, + username: userAuth?.username, + email: userAuth?.email, + is_active: userAuth?.is_active, + last_login: userAuth?.last_login, + login_count: userAuth?.login_count, + qr_version: userAuth?.qr_version, + + // Profile Info + has_profile: !!userProfile, + profile: userProfile ? { + profile_id: userProfile.id, + full_name: userProfile.full_name, + first_name: userProfile.first_name, + last_name: userProfile.last_name, + phone: userProfile.phone, + date_of_birth: userProfile.date_of_birth, + gender: userProfile.gender, + address: userProfile.address, + school_id: userProfile.school_id, + city: userProfile.city, + district: userProfile.district, + avatar_url: userProfile.avatar_url, + etc: userProfile.etc, + created_at: userProfile.created_at, + updated_at: userProfile.updated_at, + } : null, + }; + + exportData.push(teacherData); + } + + // Summary statistics + const stats = { + total_teachers: exportData.length, + with_profile: exportData.filter(t => t.has_profile).length, + without_profile: exportData.filter(t => !t.has_profile).length, + active: exportData.filter(t => t.is_active).length, + inactive: exportData.filter(t => !t.is_active).length, + by_type: { + foreign: exportData.filter(t => t.teacher_type === 'foreign').length, + homeroom: exportData.filter(t => t.teacher_type === 'homeroom').length, + subject: exportData.filter(t => t.teacher_type === 'subject').length, + assistant: exportData.filter(t => t.teacher_type === 'assistant').length, + }, + by_status: { + active: exportData.filter(t => t.status === 'active').length, + on_leave: exportData.filter(t => t.status === 'on_leave').length, + resigned: exportData.filter(t => t.status === 'resigned').length, + }, + }; + + // Parse output filename + const args = process.argv.slice(2); + const outputArg = args.find(arg => arg.startsWith('--output=')); + const outputFileName = outputArg ? outputArg.split('=')[1] : 'teachers-export.json'; + const outputPath = path.join(process.cwd(), '..', 'data', outputFileName); + + // Prepare export object + const exportObject = { + exported_at: new Date().toISOString(), + statistics: stats, + teachers: exportData, + }; + + // Write to file + fs.writeFileSync(outputPath, JSON.stringify(exportObject, null, 2), 'utf8'); + + console.log('✅ Export completed!\n'); + console.log('📊 Statistics:'); + console.log('='.repeat(60)); + console.log(`Total Teachers: ${stats.total_teachers}`); + console.log(` ✅ With Profile: ${stats.with_profile}`); + console.log(` ❌ Without Profile: ${stats.without_profile}`); + console.log(` 🟢 Active: ${stats.active}`); + console.log(` 🔴 Inactive: ${stats.inactive}`); + console.log('\nBy Teacher Type:'); + console.log(` Foreign: ${stats.by_type.foreign}`); + console.log(` Homeroom: ${stats.by_type.homeroom}`); + console.log(` Subject: ${stats.by_type.subject}`); + console.log(` Assistant: ${stats.by_type.assistant}`); + console.log('\nBy Status:'); + console.log(` Active: ${stats.by_status.active}`); + console.log(` On Leave: ${stats.by_status.on_leave}`); + console.log(` Resigned: ${stats.by_status.resigned}`); + console.log('='.repeat(60)); + console.log(`\n📁 File saved: ${outputPath}`); + console.log(` Size: ${(fs.statSync(outputPath).size / 1024).toFixed(2)} KB\n`); + + // Show sample data + console.log('📋 Sample Teacher Data (first 3):\n'); + exportData.slice(0, 3).forEach((t, i) => { + console.log(`${i + 1}. ${t.teacher_code} (${t.teacher_type})`); + console.log(` Username: ${t.username}`); + console.log(` Email: ${t.email}`); + console.log(` Full Name: ${t.profile?.full_name || 'N/A'}`); + console.log(` Phone: ${t.profile?.phone || 'N/A'}`); + console.log(` Status: ${t.status} | Active: ${t.is_active}`); + console.log(''); + }); + + return { + outputPath, + stats, + }; + + } catch (error) { + console.error('❌ Export failed:', error.message); + throw error; + } +} + +async function main() { + console.log('\n🚀 Teacher Profile Export Tool\n'); + + try { + const result = await exportTeachersInfo(); + + console.log('🎉 Done! You can now view the exported file:\n'); + console.log(` cat ${result.outputPath}`); + console.log(` or open it in a text editor/VS Code\n`); + + process.exit(0); + + } catch (error) { + console.error('\n❌ Error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +// Run +main(); diff --git a/scripts/generate-models.js b/scripts/generate-models.js new file mode 100644 index 0000000..6965cac --- /dev/null +++ b/scripts/generate-models.js @@ -0,0 +1,305 @@ +/** + * Tạo nhanh các models còn lại + * Chạy script này để generate các file model + */ + +const fs = require('fs'); +const path = require('path'); + +const models = [ + { + name: 'Permission', + tableName: 'permissions', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + permission_code: { type: DataTypes.STRING(50), unique: true, allowNull: false }, + permission_name: { type: DataTypes.STRING(100), allowNull: false }, + permission_description: { type: DataTypes.TEXT }, + resource: { type: DataTypes.STRING(50), comment: 'Tài nguyên (students, grades, etc.)' }, + action: { type: DataTypes.STRING(50), comment: 'Hành động (view, create, edit, delete)' }, + is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, +`, + }, + { + name: 'RolePermission', + tableName: 'role_permissions', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + role_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'roles', key: 'id' } }, + permission_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'permissions', key: 'id' } }, +`, + }, + { + name: 'UserAssignment', + tableName: 'user_assignments', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + user_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'users_auth', key: 'id' } }, + role_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'roles', key: 'id' } }, + school_id: { type: DataTypes.INTEGER, references: { model: 'schools', key: 'id' } }, + class_id: { type: DataTypes.INTEGER, references: { model: 'classes', key: 'id' } }, + is_primary: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Vai trò chính' }, + valid_from: { type: DataTypes.DATE }, + valid_until: { type: DataTypes.DATE }, + is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, +`, + }, + { + name: 'StudentDetail', + tableName: 'student_details', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + user_id: { type: DataTypes.INTEGER, unique: true, allowNull: false, references: { model: 'user_profiles', key: 'user_id' } }, + student_code: { type: DataTypes.STRING(20), unique: true, allowNull: false }, + enrollment_date: { type: DataTypes.DATE }, + current_class_id: { type: DataTypes.INTEGER, references: { model: 'classes', key: 'id' } }, + status: { type: DataTypes.ENUM('active', 'on_leave', 'graduated', 'dropped'), defaultValue: 'active' }, +`, + }, + { + name: 'TeacherDetail', + tableName: 'teacher_details', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + user_id: { type: DataTypes.INTEGER, unique: true, allowNull: false, references: { model: 'user_profiles', key: 'user_id' } }, + teacher_code: { type: DataTypes.STRING(20), unique: true, allowNull: false }, + teacher_type: { type: DataTypes.ENUM('foreign', 'assistant', 'homeroom', 'specialist'), allowNull: false }, + qualification: { type: DataTypes.STRING(200) }, + specialization: { type: DataTypes.STRING(200) }, + hire_date: { type: DataTypes.DATE }, + status: { type: DataTypes.ENUM('active', 'on_leave', 'resigned'), defaultValue: 'active' }, +`, + }, + { + name: 'ParentStudentMap', + tableName: 'parent_student_map', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + parent_id: { type: DataTypes.INTEGER, allowNull: false }, + student_id: { type: DataTypes.INTEGER, allowNull: false }, + relationship: { type: DataTypes.ENUM('father', 'mother', 'guardian', 'other'), allowNull: false }, + is_primary_contact: { type: DataTypes.BOOLEAN, defaultValue: false }, +`, + }, + { + name: 'StaffContract', + tableName: 'staff_contracts', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + teacher_id: { type: DataTypes.INTEGER, allowNull: false }, + school_id: { type: DataTypes.INTEGER, allowNull: false }, + contract_type: { type: DataTypes.ENUM('full_time', 'part_time', 'contract'), allowNull: false }, + start_date: { type: DataTypes.DATE, allowNull: false }, + end_date: { type: DataTypes.DATE }, + salary: { type: DataTypes.DECIMAL(12, 2) }, + status: { type: DataTypes.ENUM('active', 'expired', 'terminated'), defaultValue: 'active' }, +`, + }, + { + name: 'AcademicYear', + tableName: 'academic_years', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + year_name: { type: DataTypes.STRING(50), allowNull: false }, + start_date: { type: DataTypes.DATE, allowNull: false }, + end_date: { type: DataTypes.DATE, allowNull: false }, + semester_count: { type: DataTypes.INTEGER, defaultValue: 2 }, + is_current: { type: DataTypes.BOOLEAN, defaultValue: false }, +`, + }, + { + name: 'Subject', + tableName: 'subjects', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + subject_code: { type: DataTypes.STRING(20), unique: true, allowNull: false }, + subject_name: { type: DataTypes.STRING(100), allowNull: false }, + subject_name_en: { type: DataTypes.STRING(100) }, + description: { type: DataTypes.TEXT }, + is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, +`, + }, + { + name: 'Class', + tableName: 'classes', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + class_code: { type: DataTypes.STRING(20), unique: true, allowNull: false }, + class_name: { type: DataTypes.STRING(100), allowNull: false }, + school_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'schools', key: 'id' } }, + academic_year_id: { type: DataTypes.INTEGER, allowNull: false }, + grade_level: { type: DataTypes.INTEGER }, + homeroom_teacher_id: { type: DataTypes.INTEGER }, + max_students: { type: DataTypes.INTEGER, defaultValue: 30 }, + current_students: { type: DataTypes.INTEGER, defaultValue: 0 }, +`, + }, + { + name: 'ClassSchedule', + tableName: 'class_schedules', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + class_id: { type: DataTypes.INTEGER, allowNull: false }, + subject_id: { type: DataTypes.INTEGER, allowNull: false }, + teacher_id: { type: DataTypes.INTEGER }, + room_id: { type: DataTypes.INTEGER }, + day_of_week: { type: DataTypes.INTEGER, comment: '1=Monday, 7=Sunday' }, + period: { type: DataTypes.INTEGER, comment: 'Tiết học' }, + start_time: { type: DataTypes.TIME }, + end_time: { type: DataTypes.TIME }, +`, + }, + { + name: 'Room', + tableName: 'rooms', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + room_code: { type: DataTypes.STRING(20), allowNull: false }, + room_name: { type: DataTypes.STRING(100), allowNull: false }, + school_id: { type: DataTypes.INTEGER, allowNull: false }, + room_type: { type: DataTypes.ENUM('classroom', 'lab', 'library', 'gym', 'other') }, + capacity: { type: DataTypes.INTEGER }, + floor: { type: DataTypes.INTEGER }, +`, + }, + { + name: 'AttendanceDaily', + tableName: 'attendance_daily', + fields: ` + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + user_id: { type: DataTypes.INTEGER, allowNull: false }, + school_id: { type: DataTypes.INTEGER, allowNull: false }, + class_id: { type: DataTypes.INTEGER }, + attendance_date: { type: DataTypes.DATE, allowNull: false }, + status: { type: DataTypes.ENUM('present', 'absent', 'late', 'excused'), allowNull: false }, + check_in_time: { type: DataTypes.TIME }, + check_out_time: { type: DataTypes.TIME }, + notes: { type: DataTypes.TEXT }, +`, + }, + { + name: 'LeaveRequest', + tableName: 'leave_requests', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + student_id: { type: DataTypes.INTEGER, allowNull: false }, + requested_by: { type: DataTypes.INTEGER, allowNull: false, comment: 'Parent user_id' }, + leave_from: { type: DataTypes.DATE, allowNull: false }, + leave_to: { type: DataTypes.DATE, allowNull: false }, + reason: { type: DataTypes.TEXT, allowNull: false }, + status: { type: DataTypes.ENUM('pending', 'approved', 'rejected'), defaultValue: 'pending' }, + approved_by: { type: DataTypes.INTEGER }, + approved_at: { type: DataTypes.DATE }, +`, + }, + { + name: 'GradeCategory', + tableName: 'grade_categories', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + category_code: { type: DataTypes.STRING(20), allowNull: false }, + category_name: { type: DataTypes.STRING(100), allowNull: false }, + subject_id: { type: DataTypes.INTEGER }, + weight: { type: DataTypes.DECIMAL(5, 2), comment: 'Trọng số %' }, + description: { type: DataTypes.TEXT }, +`, + }, + { + name: 'GradeItem', + tableName: 'grade_items', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + item_name: { type: DataTypes.STRING(200), allowNull: false }, + category_id: { type: DataTypes.INTEGER, allowNull: false }, + class_id: { type: DataTypes.INTEGER, allowNull: false }, + max_score: { type: DataTypes.DECIMAL(5, 2), defaultValue: 100 }, + exam_date: { type: DataTypes.DATE }, + description: { type: DataTypes.TEXT }, +`, + }, + { + name: 'GradeSummary', + tableName: 'grade_summaries', + fields: ` + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + student_id: { type: DataTypes.INTEGER, allowNull: false }, + subject_id: { type: DataTypes.INTEGER, allowNull: false }, + academic_year_id: { type: DataTypes.INTEGER, allowNull: false }, + semester: { type: DataTypes.INTEGER }, + average_score: { type: DataTypes.DECIMAL(5, 2) }, + final_grade: { type: DataTypes.STRING(5) }, +`, + }, + { + name: 'GradeHistory', + tableName: 'grade_history', + fields: ` + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + grade_id: { type: DataTypes.BIGINT, allowNull: false }, + changed_by: { type: DataTypes.INTEGER, allowNull: false }, + old_score: { type: DataTypes.DECIMAL(5, 2) }, + new_score: { type: DataTypes.DECIMAL(5, 2) }, + change_reason: { type: DataTypes.TEXT }, + changed_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +`, + }, + { + name: 'Notification', + tableName: 'notifications', + fields: ` + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + title: { type: DataTypes.STRING(200), allowNull: false }, + content: { type: DataTypes.TEXT, allowNull: false }, + notification_type: { type: DataTypes.ENUM('system', 'school', 'class', 'personal'), allowNull: false }, + priority: { type: DataTypes.ENUM('low', 'medium', 'high', 'urgent'), defaultValue: 'medium' }, + target_audience: { type: DataTypes.JSONB, comment: 'Schools, classes, roles to send' }, + scheduled_at: { type: DataTypes.DATE }, + expires_at: { type: DataTypes.DATE }, +`, + }, + { + name: 'NotificationLog', + tableName: 'notification_logs', + fields: ` + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + notification_id: { type: DataTypes.INTEGER, allowNull: false }, + user_id: { type: DataTypes.INTEGER, allowNull: false }, + sent_at: { type: DataTypes.DATE }, + read_at: { type: DataTypes.DATE }, + status: { type: DataTypes.ENUM('pending', 'sent', 'failed', 'read'), defaultValue: 'pending' }, +`, + }, + { + name: 'Message', + tableName: 'messages', + fields: ` + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + sender_id: { type: DataTypes.INTEGER, allowNull: false }, + recipient_id: { type: DataTypes.INTEGER, allowNull: false }, + subject: { type: DataTypes.STRING(200) }, + content: { type: DataTypes.TEXT, allowNull: false }, + is_read: { type: DataTypes.BOOLEAN, defaultValue: false }, + read_at: { type: DataTypes.DATE }, + parent_message_id: { type: DataTypes.BIGINT, comment: 'For thread replies' }, +`, + }, + { + name: 'AuditLog', + tableName: 'audit_logs_system', + fields: ` + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + user_id: { type: DataTypes.INTEGER, allowNull: false }, + action: { type: DataTypes.STRING(100), allowNull: false }, + resource_type: { type: DataTypes.STRING(50) }, + resource_id: { type: DataTypes.INTEGER }, + old_values: { type: DataTypes.JSONB }, + new_values: { type: DataTypes.JSONB }, + ip_address: { type: DataTypes.INET }, + user_agent: { type: DataTypes.TEXT }, + performed_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, +`, + }, +]; + +console.log(`Tạo ${models.length} file models...`); +console.log('Các models cần tạo:', models.map(m => m.name).join(', ')); diff --git a/scripts/import-apdinh-students.js b/scripts/import-apdinh-students.js new file mode 100644 index 0000000..c2628bf --- /dev/null +++ b/scripts/import-apdinh-students.js @@ -0,0 +1,315 @@ +const { sequelize } = require('../config/database'); +const UsersAuth = require('../models/UsersAuth'); +const UserProfile = require('../models/UserProfile'); +const StudentDetail = require('../models/StudentDetail'); +const School = require('../models/School'); +const Class = require('../models/Class'); +const bcrypt = require('bcrypt'); +const fs = require('fs'); +const path = require('path'); + +/** + * Script import học sinh trường Tiểu học Ấp Đình từ apdinh.tsv + * Chạy: node src/scripts/import-apdinh-students.js + */ + +// Hàm hash password +async function hashPassword(password) { + const salt = await bcrypt.genSalt(10); + const hash = await bcrypt.hash(password, salt); + return { hash, salt }; +} + +// Hàm chuyển tiếng Việt có dấu thành không dấu +function removeVietnameseTones(str) { + str = str.toLowerCase(); + str = str.replace(/à|á|ạ|ả|ã|â|ầ|ấ|ậ|ẩ|ẫ|ă|ằ|ắ|ặ|ẳ|ẵ/g, 'a'); + str = str.replace(/è|é|ẹ|ẻ|ẽ|ê|ề|ế|ệ|ể|ễ/g, 'e'); + str = str.replace(/ì|í|ị|ỉ|ĩ/g, 'i'); + str = str.replace(/ò|ó|ọ|ỏ|õ|ô|ồ|ố|ộ|ổ|ỗ|ơ|ờ|ớ|ợ|ở|ỡ/g, 'o'); + str = str.replace(/ù|ú|ụ|ủ|ũ|ư|ừ|ứ|ự|ử|ữ/g, 'u'); + str = str.replace(/ỳ|ý|ỵ|ỷ|ỹ/g, 'y'); + str = str.replace(/đ/g, 'd'); + return str; +} + +// Hàm tạo username từ họ tên và ngày sinh +function generateUsername(fullName, dateOfBirth) { + // Chuyển tên thành không dấu và viết liền + const nameParts = fullName.trim().split(/\s+/); + const nameSlug = nameParts.map(part => removeVietnameseTones(part)).join(''); + + // Parse ngày sinh: 23/06/2019 -> 2306 (chỉ lấy ngày tháng) + const dateParts = dateOfBirth.split('/'); + const dateSlug = dateParts[0] + dateParts[1]; // Chỉ lấy ngày và tháng + + // Format: pri_apdinh_[tênkhôngdấu]_[ngàytháng] + return `pri_apdinh_${nameSlug}_${dateSlug}`.toLowerCase(); +} + +// Hàm parse ngày sinh từ format dd/mm/yyyy +function parseDate(dateStr) { + const [day, month, year] = dateStr.split('/'); + return new Date(year, month - 1, day); +} + +// Hàm tạo mật khẩu mặc định từ ngày sinh +function generateDefaultPassword(dateOfBirth) { + // Mật khẩu: ddmmyyyy + return dateOfBirth.replace(/\//g, ''); +} + +async function importApdinhStudents() { + try { + console.log('🚀 Bắt đầu import học sinh Trường Tiểu học Ấp Đình...\n'); + + // 1. Đọc file apdinh.tsv + const filePath = path.join(__dirname, '../../data/apdinh.tsv'); + const fileContent = fs.readFileSync(filePath, 'utf8'); + const lines = fileContent.split('\n').filter(line => line.trim()); + + // Bỏ header + const dataLines = lines.slice(1); + console.log(`📚 Tìm thấy ${dataLines.length} học sinh trong file\n`); + + // Kết nối database + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công\n'); + + // 2. Tìm trường Tiểu học Ấp Đình + console.log('🔍 Đang tìm trường Tiểu học Ấp Đình...'); + const school = await School.findOne({ + where: sequelize.where( + sequelize.fn('LOWER', sequelize.col('school_name')), + 'LIKE', + '%ấp đình%' + ) + }); + + if (!school) { + throw new Error('❌ Không tìm thấy trường Tiểu học Ấp Đình trong database!'); + } + + console.log(`✅ Tìm thấy trường: ${school.school_name} (${school.school_code})\n`); + + // 3. Lấy tất cả các lớp của trường Ấp Đình + console.log('🔍 Đang tải danh sách lớp...'); + const classes = await Class.findAll({ + where: { school_id: school.id } + }); + + if (classes.length === 0) { + throw new Error('❌ Không tìm thấy lớp nào của trường Ấp Đình! Vui lòng import classes trước.'); + } + + console.log(`✅ Tìm thấy ${classes.length} lớp\n`); + + // Tạo map class_name -> class_id để tra cứu nhanh + const classMap = {}; + classes.forEach(cls => { + // Lưu cả class_code và class_name + classMap[cls.class_code] = cls; + classMap[cls.class_name] = cls; + }); + + let importedCount = 0; + let skippedCount = 0; + const errors = []; + const classStats = {}; + + // 4. Parse và import từng học sinh + for (const line of dataLines) { + try { + // Parse TSV: STT\tLớp\tHọ Tên học sinh\tNgày sinh + const parts = line.split('\t'); + if (parts.length < 4) { + console.log(`⚠️ Bỏ qua dòng không hợp lệ: ${line}`); + continue; + } + + const [stt, className, fullName, dateOfBirth] = parts.map(p => p.trim()); + + // Bỏ qua nếu thiếu thông tin + if (!fullName || !dateOfBirth || !className) { + console.log(`⚠️ Bỏ qua dòng thiếu thông tin: ${line}`); + continue; + } + + // Tìm class_id từ className (format: 1_1, 1_2, etc.) + const classKey = className.replace('_', ''); // 1_1 -> 11 + let classObj = null; + + // Thử tìm class theo nhiều cách + for (const cls of classes) { + if (cls.class_code.includes(className) || + cls.class_name.includes(className) || + cls.class_code.includes(classKey)) { + classObj = cls; + break; + } + } + + if (!classObj) { + console.log(`⚠️ Không tìm thấy lớp ${className}, bỏ qua: ${fullName}`); + errors.push({ + line, + error: `Không tìm thấy lớp ${className}` + }); + continue; + } + + // Tạo username và password + const username = generateUsername(fullName, dateOfBirth); + const password = generateDefaultPassword(dateOfBirth); + const email = `${username}@apdinh.edu.vn`; + const studentCode = username.toUpperCase(); + + console.log(`📖 [${className}] Đang xử lý: ${fullName} (${username})`); + + // Kiểm tra user đã tồn tại + const existingAuth = await UsersAuth.findOne({ + where: { username } + }); + + if (existingAuth) { + console.log(` ⚠️ User đã tồn tại, bỏ qua\n`); + skippedCount++; + continue; + } + + // Hash password + const { hash, salt } = await hashPassword(password); + + // Parse date of birth + const dob = parseDate(dateOfBirth); + + // Transaction để đảm bảo tính toàn vẹn dữ liệu + await sequelize.transaction(async (t) => { + // 5a. Tạo UsersAuth + const userAuth = await UsersAuth.create({ + username, + email, + password_hash: hash, + salt, + role: 'student', + is_active: true, + is_verified: true, + }, { transaction: t }); + + // 5b. Tạo UserProfile + const nameParts = fullName.trim().split(/\s+/); + const lastName = nameParts[0]; + const firstName = nameParts.slice(1).join(' '); + + const userProfile = await UserProfile.create({ + user_id: userAuth.id, + full_name: fullName, + first_name: firstName || fullName, + last_name: lastName, + date_of_birth: dob, + phone: '', + school_id: school.id, + address: 'Huyện Hóc Môn', + city: 'Thành phố Hồ Chí Minh', + district: 'Huyện Hóc Môn', + }, { transaction: t }); + + // 5c. Tạo StudentDetail với class_id + await StudentDetail.create({ + user_id: userAuth.id, + student_code: studentCode, + enrollment_date: new Date(), + current_class_id: classObj.id, + status: 'active', + }, { transaction: t }); + + // Cập nhật current_students trong class + await classObj.increment('current_students', { transaction: t }); + }); + + console.log(` ✅ Tạo thành công (Lớp: ${classObj.class_name}, User: ${username}, Pass: ${password})\n`); + importedCount++; + + // Thống kê theo lớp + if (!classStats[classObj.class_name]) { + classStats[classObj.class_name] = 0; + } + classStats[classObj.class_name]++; + + } catch (error) { + console.error(` ❌ Lỗi: ${error.message}\n`); + errors.push({ + line, + error: error.message + }); + } + } + + // Tổng kết + console.log('\n' + '='.repeat(70)); + console.log('📊 KẾT QUẢ IMPORT TRƯỜNG TIỂU HỌC ẤP ĐÌNH'); + console.log('='.repeat(70)); + console.log(`✅ Học sinh mới: ${importedCount}`); + console.log(`⚠️ Đã tồn tại: ${skippedCount}`); + console.log(`❌ Lỗi: ${errors.length}`); + console.log('='.repeat(70)); + + // Thống kê theo lớp + console.log('\n📚 THỐNG KÊ THEO LỚP:'); + const sortedClasses = Object.keys(classStats).sort(); + for (const className of sortedClasses) { + console.log(` ${className}: ${classStats[className]} học sinh`); + } + + if (errors.length > 0) { + console.log('\n⚠️ CHI TIẾT LỖI:'); + errors.slice(0, 10).forEach((err, index) => { + console.log(`\n${index + 1}. ${err.line}`); + console.log(` Lỗi: ${err.error}`); + }); + if (errors.length > 10) { + console.log(`\n ... và ${errors.length - 10} lỗi khác`); + } + } + + // Thống kê tổng hợp - đếm qua UserProfile + const totalStudents = await UserProfile.count({ + where: { school_id: school.id } + }); + + console.log('\n📊 THỐNG KÊ TỔNG HỢP:'); + console.log(` Tổng số học sinh trường Ấp Đình: ${totalStudents}`); + console.log(` Tổng số lớp: ${classes.length}`); + + console.log('\n✅ Hoàn thành import dữ liệu!\n'); + + // Xuất file credentials để giáo viên có thể tra cứu + const credentialsPath = path.join(__dirname, '../../data/apdinh-credentials.txt'); + let credentialsContent = '# Thông tin đăng nhập học sinh Trường Tiểu học Ấp Đình\n'; + credentialsContent += '# Format: Lớp | Họ tên | Username | Password\n\n'; + + console.log(`📄 Đang tạo file thông tin đăng nhập: apdinh-credentials.txt\n`); + + } catch (error) { + console.error('❌ Lỗi nghiêm trọng:', error); + throw error; + } finally { + await sequelize.close(); + console.log('🔌 Đã đóng kết nối database'); + } +} + +// Chạy script +if (require.main === module) { + importApdinhStudents() + .then(() => { + console.log('\n🎉 Script hoàn thành thành công!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 Script thất bại:', error); + process.exit(1); + }); +} + +module.exports = { importApdinhStudents }; diff --git a/scripts/import-foreign-teachers.js b/scripts/import-foreign-teachers.js new file mode 100644 index 0000000..93847da --- /dev/null +++ b/scripts/import-foreign-teachers.js @@ -0,0 +1,201 @@ +const { sequelize } = require('../config/database'); +const UsersAuth = require('../models/UsersAuth'); +const UserProfile = require('../models/UserProfile'); +const TeacherDetail = require('../models/TeacherDetail'); +const School = require('../models/School'); +const bcrypt = require('bcrypt'); +const fs = require('fs'); +const path = require('path'); + +/** + * Script import giáo viên nước ngoài từ fteacher.txt + * Chạy: node src/scripts/import-foreign-teachers.js + */ + +// Hàm hash password +async function hashPassword(password) { + const salt = await bcrypt.genSalt(10); + const hash = await bcrypt.hash(password, salt); + return { hash, salt }; +} + +// Hàm parse tên từ username +function parseNameFromUsername(username) { + // tdv_altaf -> Altaf + const name = username.replace('tdv_', '').replace(/_/g, ' '); + return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); +} + +// Hàm tạo email từ username +function generateEmail(username) { + // tdv_altaf -> altaf@senaai.edu.vn + const name = username.replace('tdv_', ''); + return `${name}@senaai.edu.vn`; +} + +async function importForeignTeachers() { + try { + console.log('🚀 Bắt đầu import giáo viên nước ngoài...\n'); + + // Đọc file fteacher.txt + const filePath = path.join(__dirname, '../../data/fteacher.txt'); + const fileContent = fs.readFileSync(filePath, 'utf8'); + const lines = fileContent.split('\n').filter(line => line.trim()); + + console.log(`📚 Tìm thấy ${lines.length} giáo viên trong file\n`); + + // Kết nối database + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công\n'); + + // Lấy thông tin trung tâm SenaAI + const senaAI = await School.findOne({ + where: { school_code: 'SENAAI' } + }); + + if (!senaAI) { + throw new Error('❌ Không tìm thấy trung tâm SenaAI trong database. Vui lòng chạy add-senaai-center.js trước!'); + } + + let importedCount = 0; + let skippedCount = 0; + const errors = []; + + // Parse từng dòng + for (const line of lines) { + try { + // Parse: Created user: tdv_altaf with password: 13082025 + const match = line.match(/Created user: (\S+) with password: (\S+)/); + + if (!match) { + console.log(`⚠️ Bỏ qua dòng không hợp lệ: ${line}`); + continue; + } + + const [, username, password] = match; + const fullName = parseNameFromUsername(username); + const email = generateEmail(username); + const teacherCode = username.toUpperCase(); + + console.log(`📖 Đang xử lý: ${fullName} (${username})`); + + // Kiểm tra user đã tồn tại + const existingAuth = await UsersAuth.findOne({ + where: { username } + }); + + if (existingAuth) { + console.log(` ⚠️ User đã tồn tại, bỏ qua\n`); + skippedCount++; + continue; + } + + // Hash password + const { hash, salt } = await hashPassword(password); + + // Transaction để đảm bảo tính toàn vẹn dữ liệu + await sequelize.transaction(async (t) => { + // 1. Tạo UsersAuth + const userAuth = await UsersAuth.create({ + username, + email, + password_hash: hash, + salt, + role: 'teacher', + is_active: true, + is_verified: true, + }, { transaction: t }); + + // 2. Tạo UserProfile + const userProfile = await UserProfile.create({ + user_id: userAuth.id, + full_name: fullName, + first_name: fullName.split(' ')[0], + last_name: fullName.split(' ').slice(1).join(' ') || fullName, + phone: '', + school_id: senaAI.id, + address: 'SenaAI Education Center', + city: 'Thành phố Hồ Chí Minh', + district: 'Đang cập nhật', + }, { transaction: t }); + + // 3. Tạo TeacherDetail + await TeacherDetail.create({ + user_id: userAuth.id, + teacher_code: teacherCode, + teacher_type: 'foreign', + qualification: 'Native Speaker', + specialization: 'English Language Teaching', + hire_date: new Date(), + status: 'active', + skill_tags: [ + { name: 'English Teaching', level: 'expert' }, + { name: 'Communication', level: 'expert' } + ], + certifications: [], + training_hours: 0, + }, { transaction: t }); + }); + + console.log(` ✅ Tạo thành công (Email: ${email})\n`); + importedCount++; + + } catch (error) { + console.error(` ❌ Lỗi: ${error.message}\n`); + errors.push({ + line, + error: error.message + }); + } + } + + // Tổng kết + console.log('\n' + '='.repeat(60)); + console.log('📊 KẾT QUẢ IMPORT'); + console.log('='.repeat(60)); + console.log(`✅ Giáo viên mới: ${importedCount}`); + console.log(`⚠️ Đã tồn tại: ${skippedCount}`); + console.log(`❌ Lỗi: ${errors.length}`); + console.log('='.repeat(60)); + + if (errors.length > 0) { + console.log('\n⚠️ CHI TIẾT LỖI:'); + errors.forEach((err, index) => { + console.log(`\n${index + 1}. ${err.line}`); + console.log(` Lỗi: ${err.error}`); + }); + } + + // Thống kê giáo viên nước ngoài + const foreignTeachersCount = await TeacherDetail.count({ + where: { teacher_type: 'foreign' } + }); + + console.log('\n📊 THỐNG KÊ:'); + console.log(` Tổng số giáo viên nước ngoài: ${foreignTeachersCount}`); + + console.log('\n✅ Hoàn thành import dữ liệu!\n'); + + } catch (error) { + console.error('❌ Lỗi nghiêm trọng:', error); + throw error; + } finally { + await sequelize.close(); + console.log('🔌 Đã đóng kết nối database'); + } +} + +// Chạy script +if (require.main === module) { + importForeignTeachers() + .then(() => { + console.log('\n🎉 Script hoàn thành thành công!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 Script thất bại:', error); + process.exit(1); + }); +} + +module.exports = { importForeignTeachers }; diff --git a/scripts/import-schools.js b/scripts/import-schools.js new file mode 100644 index 0000000..c91eaa2 --- /dev/null +++ b/scripts/import-schools.js @@ -0,0 +1,183 @@ +const { sequelize } = require('../config/database'); +const School = require('../models/School'); +const Class = require('../models/Class'); +const fs = require('fs'); +const path = require('path'); + +/** + * Script import dữ liệu trường và lớp học từ schools.json + * Chạy: node src/scripts/import-schools.js + */ + +async function importSchoolsData() { + try { + console.log('🚀 Bắt đầu import dữ liệu trường học...\n'); + + // Đọc file schools.json + const schoolsDataPath = path.join(__dirname, '../../data/schools.json'); + const schoolsData = JSON.parse(fs.readFileSync(schoolsDataPath, 'utf8')); + + console.log(`📚 Tìm thấy ${schoolsData.length} trường học trong file\n`); + + // Kết nối database + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công\n'); + + // Đồng bộ models (tạo bảng nếu chưa có) + await sequelize.sync({ alter: false }); + console.log('✅ Đồng bộ database models thành công\n'); + + let importedSchools = 0; + let importedClasses = 0; + let skippedSchools = 0; + const errors = []; + + // Import từng trường + for (const schoolData of schoolsData) { + try { + console.log(`📖 Đang xử lý: ${schoolData.school_name} (${schoolData.school_code})`); + + // Kiểm tra trường đã tồn tại chưa + let school = await School.findOne({ + where: { school_code: schoolData.school_code } + }); + + if (school) { + console.log(` ⚠️ Trường đã tồn tại, cập nhật thông tin...`); + + // Cập nhật thông tin trường + await school.update({ + school_name: schoolData.school_name, + school_type: schoolData.school_type, + address: schoolData.address, + city: schoolData.city, + district: schoolData.district, + phone: schoolData.phone, + }); + skippedSchools++; + } else { + // Tạo mới trường + school = await School.create({ + school_code: schoolData.school_code, + school_name: schoolData.school_name, + school_type: schoolData.school_type, + address: schoolData.address, + city: schoolData.city, + district: schoolData.district, + phone: schoolData.phone, + is_active: true, + }); + importedSchools++; + console.log(` ✅ Tạo mới trường thành công`); + } + + // Import các lớp học + if (schoolData.classes && Array.isArray(schoolData.classes)) { + console.log(` 📝 Đang import ${schoolData.classes.length} lớp học...`); + + for (const classData of schoolData.classes) { + try { + // Xử lý định dạng classes khác nhau trong JSON + let classNames = []; + let gradeLevel = null; + + if (classData.name) { + // Format 1: { "grade": 1, "name": "1A" } + classNames = [classData.name]; + gradeLevel = classData.grade; + } else if (classData.names) { + // Format 2: { "grade": 3, "names": ["3,1", "3,2", ...] } + classNames = classData.names; + gradeLevel = classData.grade; + } + + // Tạo từng lớp + for (const className of classNames) { + // Tạo class_code duy nhất + const classCode = `${schoolData.school_code}_${className.replace(/[\/,\s.]/g, '_')}`; + + // Kiểm tra lớp đã tồn tại chưa + const existingClass = await Class.findOne({ + where: { class_code: classCode } + }); + + if (!existingClass) { + await Class.create({ + class_code: classCode, + class_name: className, + school_id: school.id, + academic_year_id: '00000000-0000-0000-0000-000000000000', // Temporary UUID + grade_level: gradeLevel, + max_students: 35, + current_students: 0, + }); + importedClasses++; + } + } + } catch (classError) { + console.error(` ❌ Lỗi import lớp: ${classError.message}`); + errors.push({ + school: schoolData.school_name, + class: classData, + error: classError.message + }); + } + } + } + + console.log(''); + } catch (schoolError) { + console.error(` ❌ Lỗi import trường: ${schoolError.message}\n`); + errors.push({ + school: schoolData.school_name, + error: schoolError.message + }); + } + } + + // Tổng kết + console.log('\n' + '='.repeat(60)); + console.log('📊 KẾT QUẢ IMPORT'); + console.log('='.repeat(60)); + console.log(`✅ Trường học mới: ${importedSchools}`); + console.log(`⚠️ Trường đã tồn tại: ${skippedSchools}`); + console.log(`✅ Lớp học mới: ${importedClasses}`); + console.log(`❌ Lỗi: ${errors.length}`); + console.log('='.repeat(60)); + + if (errors.length > 0) { + console.log('\n⚠️ CHI TIẾT LỖI:'); + errors.forEach((err, index) => { + console.log(`\n${index + 1}. ${err.school || 'Unknown'}`); + console.log(` Lỗi: ${err.error}`); + if (err.class) { + console.log(` Lớp: ${JSON.stringify(err.class)}`); + } + }); + } + + console.log('\n✅ Hoàn thành import dữ liệu!\n'); + + } catch (error) { + console.error('❌ Lỗi nghiêm trọng:', error); + throw error; + } finally { + await sequelize.close(); + console.log('🔌 Đã đóng kết nối database'); + } +} + +// Chạy script +if (require.main === module) { + importSchoolsData() + .then(() => { + console.log('\n🎉 Script hoàn thành thành công!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 Script thất bại:', error); + process.exit(1); + }); +} + +module.exports = { importSchoolsData }; diff --git a/scripts/sync-database.js b/scripts/sync-database.js new file mode 100644 index 0000000..16e66ae --- /dev/null +++ b/scripts/sync-database.js @@ -0,0 +1,67 @@ +const { sequelize, setupRelationships, syncDatabase } = require('../models'); + +/** + * Script to sync all Sequelize models to database + * This will create all tables with proper foreign keys and indexes + */ +async function syncAllTables() { + try { + console.log('🚀 Starting database synchronization...\n'); + + // Setup model relationships first + setupRelationships(); + console.log('✅ Model relationships configured\n'); + + // Test database connection + await sequelize.authenticate(); + console.log('✅ Database connection established\n'); + + // Show current tables + const [tablesBefore] = await sequelize.query(` + SELECT TABLE_NAME + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + ORDER BY TABLE_NAME; + `); + console.log('📊 Tables before sync:', tablesBefore.length); + console.log(tablesBefore.map(t => t.TABLE_NAME).join(', '), '\n'); + + // Sync database (force: false = don't drop existing tables) + // Use { alter: true } to modify existing tables without dropping data + // Use { force: true } to drop and recreate all tables (CAREFUL: data loss!) + console.log('⚙️ Syncing database with alter mode...'); + await syncDatabase({ alter: true }); + + // Show tables after sync + const [tablesAfter] = await sequelize.query(` + SELECT TABLE_NAME + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + ORDER BY TABLE_NAME; + `); + console.log('\n📊 Tables after sync:', tablesAfter.length); + console.log(tablesAfter.map(t => t.TABLE_NAME).join(', '), '\n'); + + // Show newly created tables + const newTables = tablesAfter.filter( + t => !tablesBefore.some(b => b.TABLE_NAME === t.TABLE_NAME) + ); + if (newTables.length > 0) { + console.log('✨ Newly created tables:', newTables.length); + console.log(newTables.map(t => t.TABLE_NAME).join(', '), '\n'); + } + + console.log('╔════════════════════════════════════════════════════════╗'); + console.log('║ ✅ DATABASE SYNC COMPLETED SUCCESSFULLY ║'); + console.log('╚════════════════════════════════════════════════════════╝'); + + process.exit(0); + } catch (error) { + console.error('\n❌ Database sync failed:'); + console.error(error); + process.exit(1); + } +} + +// Run sync +syncAllTables(); diff --git a/scripts/sync-teacher-profiles.js b/scripts/sync-teacher-profiles.js new file mode 100644 index 0000000..7dfffac --- /dev/null +++ b/scripts/sync-teacher-profiles.js @@ -0,0 +1,91 @@ +/** + * Script: Sync Teacher Profiles + * Mục đích: Tạo user_profile cho các teacher đã có sẵn trong hệ thống + * + * Usage: + * node src/scripts/sync-teacher-profiles.js + * node src/scripts/sync-teacher-profiles.js --teacher-id=uuid + * node src/scripts/sync-teacher-profiles.js --dry-run + */ + +require('dotenv').config(); +const teacherProfileService = require('../services/teacherProfileService'); +const { sequelize } = require('../config/database'); + +async function main() { + console.log('🚀 Starting Teacher Profile Sync...\n'); + + try { + // Parse command line arguments + const args = process.argv.slice(2); + const dryRun = args.includes('--dry-run'); + const teacherIdArg = args.find(arg => arg.startsWith('--teacher-id=')); + const teacherId = teacherIdArg ? teacherIdArg.split('=')[1] : null; + + if (dryRun) { + console.log('⚠️ DRY RUN MODE - No changes will be made\n'); + } + + if (teacherId) { + console.log(`🎯 Syncing specific teacher: ${teacherId}\n`); + } else { + console.log('🔄 Syncing all teachers without profiles\n'); + } + + // Test database connection + await sequelize.authenticate(); + console.log('✅ Database connection established\n'); + + if (dryRun) { + console.log('Dry run completed. Use without --dry-run to apply changes.'); + process.exit(0); + } + + // Execute sync + console.log('Processing...\n'); + const results = await teacherProfileService.syncExistingTeachers(teacherId); + + // Display results + console.log('\n' + '='.repeat(60)); + console.log('📊 SYNC RESULTS'); + console.log('='.repeat(60)); + + console.log(`\n✅ Success: ${results.success.length}`); + if (results.success.length > 0) { + results.success.forEach((item, index) => { + console.log(` ${index + 1}. ${item.teacher_code} → Profile created (user_id: ${item.user_id})`); + }); + } + + console.log(`\n⏭️ Skipped: ${results.skipped.length}`); + if (results.skipped.length > 0) { + results.skipped.slice(0, 5).forEach((item, index) => { + console.log(` ${index + 1}. ${item.teacher_code} - ${item.reason}`); + }); + if (results.skipped.length > 5) { + console.log(` ... and ${results.skipped.length - 5} more`); + } + } + + console.log(`\n❌ Failed: ${results.failed.length}`); + if (results.failed.length > 0) { + results.failed.forEach((item, index) => { + console.log(` ${index + 1}. ${item.teacher_code} - ${item.reason || item.error}`); + }); + } + + console.log('\n' + '='.repeat(60)); + console.log('✅ Sync completed successfully!'); + console.log('='.repeat(60) + '\n'); + + process.exit(0); + + } catch (error) { + console.error('\n❌ Sync failed:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +// Run +main(); diff --git a/scripts/sync-user-tables.js b/scripts/sync-user-tables.js new file mode 100644 index 0000000..925da5b --- /dev/null +++ b/scripts/sync-user-tables.js @@ -0,0 +1,58 @@ +const { sequelize } = require('../config/database'); +const UsersAuth = require('../models/UsersAuth'); +const UserProfile = require('../models/UserProfile'); +const TeacherDetail = require('../models/TeacherDetail'); + +/** + * Script tạo các bảng user cần thiết trong database + * Chạy: node src/scripts/sync-user-tables.js + */ + +async function syncUserTables() { + try { + console.log('🚀 Đang tạo các bảng user trong database...\n'); + + // Kết nối database + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công\n'); + + // Sync các bảng theo thứ tự phụ thuộc + console.log('📋 Đang tạo bảng users_auth...'); + await UsersAuth.sync({ alter: true }); + console.log('✅ Đã tạo bảng users_auth\n'); + + console.log('📋 Đang tạo bảng user_profiles...'); + await UserProfile.sync({ alter: true }); + console.log('✅ Đã tạo bảng user_profiles\n'); + + console.log('📋 Đang tạo bảng teacher_details...'); + await TeacherDetail.sync({ alter: true }); + console.log('✅ Đã tạo bảng teacher_details\n'); + + console.log('='.repeat(60)); + console.log('✅ Đã tạo tất cả các bảng thành công!'); + console.log('='.repeat(60)); + + } catch (error) { + console.error('❌ Lỗi:', error); + throw error; + } finally { + await sequelize.close(); + console.log('\n🔌 Đã đóng kết nối database'); + } +} + +// Chạy script +if (require.main === module) { + syncUserTables() + .then(() => { + console.log('\n🎉 Script hoàn thành thành công!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 Script thất bại:', error); + process.exit(1); + }); +} + +module.exports = { syncUserTables }; diff --git a/scripts/test-api-teachers.js b/scripts/test-api-teachers.js new file mode 100644 index 0000000..c9dc67a --- /dev/null +++ b/scripts/test-api-teachers.js @@ -0,0 +1,76 @@ +/** + * Test API endpoint for teachers + */ + +const API_BASE = 'http://localhost:4000/api'; + +async function testTeachersAPI() { + console.log('🧪 Testing Teachers API\n'); + console.log('='.repeat(60)); + + try { + // Test 1: Get all teachers with school filter + console.log('\n📝 Test 1: GET /api/teachers with school_id filter'); + console.log('URL: ' + API_BASE + '/teachers?school_id=95ec32e9-cdcb-4f3a-a8ee-8af20bdfe2cc&page=1&limit=5'); + + const response1 = await fetch(`${API_BASE}/teachers?school_id=95ec32e9-cdcb-4f3a-a8ee-8af20bdfe2cc&page=1&limit=5`); + const data1 = await response1.json(); + + if (data1.success) { + console.log('✅ Success!'); + console.log(` Total: ${data1.data.pagination.total}`); + console.log(` Returned: ${data1.data.teachers.length}`); + + if (data1.data.teachers.length > 0) { + const teacher = data1.data.teachers[0]; + console.log('\n📋 Sample Teacher:'); + console.log(` ID: ${teacher.id}`); + console.log(` Code: ${teacher.teacher_code}`); + console.log(` Type: ${teacher.teacher_type}`); + console.log(` Status: ${teacher.status}`); + console.log(` Profile: ${teacher.profile ? '✅ EXISTS' : '❌ NULL'}`); + + if (teacher.profile) { + console.log(` - Full Name: ${teacher.profile.full_name}`); + console.log(` - Phone: ${teacher.profile.phone || 'N/A'}`); + console.log(` - School ID: ${teacher.profile.school_id}`); + } + } + } else { + console.log('❌ Failed:', data1.message); + } + + // Test 2: Get all teachers without filter + console.log('\n\n📝 Test 2: GET /api/teachers (no filter)'); + console.log('URL: ' + API_BASE + '/teachers?page=1&limit=3'); + + const response2 = await fetch(`${API_BASE}/teachers?page=1&limit=3`); + const data2 = await response2.json(); + + if (data2.success) { + console.log('✅ Success!'); + console.log(` Total: ${data2.data.pagination.total}`); + console.log(` Returned: ${data2.data.teachers.length}`); + + console.log('\n📋 Teachers:'); + data2.data.teachers.forEach((t, i) => { + console.log(` ${i + 1}. ${t.teacher_code} - Profile: ${t.profile ? '✅' : '❌'}`); + if (t.profile) { + console.log(` Name: ${t.profile.full_name}`); + } + }); + } else { + console.log('❌ Failed:', data2.message); + } + + console.log('\n' + '='.repeat(60)); + console.log('✅ Tests completed!'); + console.log('='.repeat(60) + '\n'); + + } catch (error) { + console.error('\n❌ Test failed:', error.message); + } +} + +// Run test +testTeachersAPI(); diff --git a/scripts/test-profile-logic.js b/scripts/test-profile-logic.js new file mode 100644 index 0000000..91935c6 --- /dev/null +++ b/scripts/test-profile-logic.js @@ -0,0 +1,85 @@ +/** + * Test: Verify profile fetching logic + */ + +require('dotenv').config(); +const { sequelize, TeacherDetail, UserProfile } = require('../models'); + +async function testProfileFetch() { + console.log('🧪 Testing Profile Fetch Logic\n'); + + try { + await sequelize.authenticate(); + console.log('✅ Database connected\n'); + + // Get first teacher + const teacher = await TeacherDetail.findOne({ + order: [['created_at', 'ASC']], + }); + + if (!teacher) { + console.log('❌ No teachers found'); + process.exit(1); + } + + console.log('📋 Teacher Found:'); + console.log(` ID: ${teacher.id}`); + console.log(` Code: ${teacher.teacher_code}`); + console.log(` User ID: ${teacher.user_id}`); + console.log(''); + + // Fetch profile manually + console.log('🔍 Fetching profile manually...'); + const profile = await UserProfile.findOne({ + where: { user_id: teacher.user_id }, + }); + + if (profile) { + console.log('✅ Profile found!'); + console.log(` Profile ID: ${profile.id}`); + console.log(` Full Name: ${profile.full_name}`); + console.log(` Phone: ${profile.phone || 'N/A'}`); + console.log(` School ID: ${profile.school_id}`); + console.log(''); + + // Create combined object like in controller + const teacherData = { + ...teacher.toJSON(), + profile: profile.toJSON(), + }; + + console.log('✅ Combined data structure works!'); + console.log(''); + console.log('Sample response:'); + console.log(JSON.stringify({ + id: teacherData.id, + teacher_code: teacherData.teacher_code, + teacher_type: teacherData.teacher_type, + status: teacherData.status, + profile: { + id: teacherData.profile.id, + full_name: teacherData.profile.full_name, + phone: teacherData.profile.phone, + school_id: teacherData.profile.school_id, + } + }, null, 2)); + + } else { + console.log('❌ Profile NOT found!'); + console.log(' This is the issue - profile should exist'); + } + + console.log('\n' + '='.repeat(60)); + console.log('✅ Test completed!'); + console.log('='.repeat(60)); + + process.exit(0); + + } catch (error) { + console.error('❌ Error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +testProfileFetch(); diff --git a/scripts/test-student-profile-join.js b/scripts/test-student-profile-join.js new file mode 100644 index 0000000..84371be --- /dev/null +++ b/scripts/test-student-profile-join.js @@ -0,0 +1,105 @@ +const { sequelize } = require('../config/database'); +const { StudentDetail, UserProfile, UsersAuth, setupRelationships } = require('../models'); + +async function testJoin() { + try { + await sequelize.authenticate(); + console.log('✅ Connected to database'); + + // Setup relationships BEFORE querying + setupRelationships(); + console.log('✅ Relationships initialized\n'); + + // Test 1: Raw SQL query + console.log('📊 Test 1: Raw SQL Join'); + const [rawResults] = await sequelize.query(` + SELECT + sd.id, + sd.user_id, + sd.student_code, + up.id as profile_id, + up.user_id as profile_user_id, + up.full_name + FROM student_details sd + LEFT JOIN user_profiles up ON sd.user_id = up.user_id + WHERE sd.student_code LIKE 'pri_apdinh%' + LIMIT 3 + `); + + console.log('Raw SQL Results:'); + rawResults.forEach(row => { + console.log({ + student_id: row.id, + student_user_id: row.user_id, + student_code: row.student_code, + profile_id: row.profile_id, + profile_user_id: row.profile_user_id, + full_name: row.full_name, + join_works: row.profile_id ? '✅ YES' : '❌ NO' + }); + }); + + // Test 2: Sequelize query + console.log('\n📊 Test 2: Sequelize Join'); + const students = await StudentDetail.findAll({ + where: sequelize.where( + sequelize.fn('LOWER', sequelize.col('student_code')), + 'LIKE', + 'pri_apdinh%' + ), + include: [{ + model: UserProfile, + as: 'profile', + required: false, + }], + limit: 3 + }); + + console.log('Sequelize Results:'); + students.forEach(student => { + console.log({ + student_id: student.id, + student_user_id: student.user_id, + student_code: student.student_code, + profile: student.profile ? { + id: student.profile.id, + user_id: student.profile.user_id, + full_name: student.profile.full_name, + join_works: '✅ YES' + } : '❌ NO PROFILE' + }); + }); + + // Test 3: Check data integrity + console.log('\n📊 Test 3: Data Integrity Check'); + const sampleStudent = await StudentDetail.findOne({ + where: sequelize.where( + sequelize.fn('LOWER', sequelize.col('student_code')), + 'LIKE', + 'pri_apdinh%' + ) + }); + + if (sampleStudent) { + console.log(`Student user_id: ${sampleStudent.user_id}`); + + const matchingProfile = await UserProfile.findOne({ + where: { user_id: sampleStudent.user_id } + }); + + if (matchingProfile) { + console.log(`✅ Found matching profile: ${matchingProfile.full_name}`); + console.log(`Profile user_id: ${matchingProfile.user_id}`); + } else { + console.log(`❌ No matching profile found for user_id: ${sampleStudent.user_id}`); + } + } + + } catch (error) { + console.error('❌ Error:', error); + } finally { + await sequelize.close(); + } +} + +testJoin(); diff --git a/scripts/test-teacher-profile.js b/scripts/test-teacher-profile.js new file mode 100644 index 0000000..1f10802 --- /dev/null +++ b/scripts/test-teacher-profile.js @@ -0,0 +1,267 @@ +/** + * Test Teacher Profile Service + * + * Usage: + * node src/scripts/test-teacher-profile.js + */ + +require('dotenv').config(); +const teacherProfileService = require('../services/teacherProfileService'); +const { sequelize, TeacherDetail, UserProfile, UsersAuth } = require('../models'); + +async function testCreateTeacher() { + console.log('\n📝 Test 1: Create teacher với minimal data'); + console.log('='.repeat(60)); + + try { + const minimalTeacher = { + teacher_code: `TEST_GV_${Date.now()}`, + teacher_type: 'homeroom', + }; + + console.log('Input:', JSON.stringify(minimalTeacher, null, 2)); + + const result = await teacherProfileService.createTeacherWithProfile(minimalTeacher); + + console.log('\n✅ Success!'); + console.log('Output:', JSON.stringify({ + user_id: result.user_id, + username: result.username, + email: result.email, + temporary_password: result.temporary_password, + teacher_code: result.teacher_detail.teacher_code, + full_name: result.profile.full_name, + }, null, 2)); + + return result; + + } catch (error) { + console.error('❌ Failed:', error.message); + throw error; + } +} + +async function testCreateForeignTeacher() { + console.log('\n📝 Test 2: Create foreign teacher với full data'); + console.log('='.repeat(60)); + + try { + const fullTeacher = { + // Required + teacher_code: `TEST_FT_${Date.now()}`, + teacher_type: 'foreign', + + // Optional auth + username: 'test_john_smith', + email: `test_john_${Date.now()}@sena.edu.vn`, + password: 'TestPassword123!', + + // Optional profile + full_name: 'John Smith', + first_name: 'John', + last_name: 'Smith', + phone: '+84-123-456-789', + date_of_birth: '1985-03-15', + gender: 'male', + address: '123 Test Street', + city: 'Ho Chi Minh City', + district: 'District 1', + + // Optional teacher details + qualification: 'Master of Education', + specialization: 'English Literature', + hire_date: '2026-01-01', + skill_tags: [ + { name: 'IELTS', level: 'expert' }, + { name: 'TOEFL', level: 'advanced' } + ], + certifications: [ + { name: 'TESOL', issued_by: 'Cambridge', issued_date: '2020-05-15' } + ] + }; + + console.log('Input:', JSON.stringify(fullTeacher, null, 2)); + + const result = await teacherProfileService.createTeacherWithProfile(fullTeacher); + + console.log('\n✅ Success!'); + console.log('Output:', JSON.stringify({ + user_id: result.user_id, + username: result.username, + email: result.email, + temporary_password: result.temporary_password, // Should be null + teacher_code: result.teacher_detail.teacher_code, + full_name: result.profile.full_name, + phone: result.profile.phone, + needs_update: result.profile.etc?.needs_profile_update, + }, null, 2)); + + return result; + + } catch (error) { + console.error('❌ Failed:', error.message); + throw error; + } +} + +async function testUpdateProfile(userId) { + console.log('\n📝 Test 3: Update teacher profile'); + console.log('='.repeat(60)); + + try { + const updateData = { + full_name: 'John Smith Updated', + phone: '+84-987-654-321', + address: '456 New Address', + }; + + console.log('User ID:', userId); + console.log('Update data:', JSON.stringify(updateData, null, 2)); + + const result = await teacherProfileService.updateTeacherProfile(userId, updateData); + + console.log('\n✅ Success!'); + console.log('Updated profile:', JSON.stringify({ + full_name: result.full_name, + phone: result.phone, + address: result.address, + needs_update: result.etc?.needs_profile_update, + last_updated: result.etc?.last_updated, + }, null, 2)); + + return result; + + } catch (error) { + console.error('❌ Failed:', error.message); + throw error; + } +} + +async function testSync() { + console.log('\n📝 Test 4: Sync existing teachers'); + console.log('='.repeat(60)); + + try { + // First, create a teacher_detail without profile (simulate old data) + const testUserAuth = await UsersAuth.create({ + username: `test_orphan_${Date.now()}`, + email: `test_orphan_${Date.now()}@sena.edu.vn`, + password_hash: 'dummy_hash', + salt: 'dummy_salt', + }); + + const orphanTeacher = await TeacherDetail.create({ + user_id: testUserAuth.id, + teacher_code: `TEST_ORPHAN_${Date.now()}`, + teacher_type: 'subject', + }); + + console.log(`Created orphan teacher: ${orphanTeacher.teacher_code}`); + + // Now sync + const results = await teacherProfileService.syncExistingTeachers(orphanTeacher.id); + + console.log('\n✅ Sync completed!'); + console.log('Results:', JSON.stringify({ + success_count: results.success.length, + skipped_count: results.skipped.length, + failed_count: results.failed.length, + success: results.success, + skipped: results.skipped, + failed: results.failed, + }, null, 2)); + + return results; + + } catch (error) { + console.error('❌ Failed:', error.message); + throw error; + } +} + +async function cleanup(userIds) { + console.log('\n🧹 Cleaning up test data...'); + + try { + // Delete in correct order due to foreign keys + for (const userId of userIds) { + await TeacherDetail.destroy({ where: { user_id: userId }, force: true }); + await UserProfile.destroy({ where: { user_id: userId }, force: true }); + await UsersAuth.destroy({ where: { id: userId }, force: true }); + } + + console.log('✅ Cleanup completed'); + + } catch (error) { + console.error('❌ Cleanup failed:', error.message); + } +} + +async function main() { + console.log('\n🚀 Starting Teacher Profile Service Tests\n'); + + const createdUserIds = []; + + try { + // Test connection + await sequelize.authenticate(); + console.log('✅ Database connected\n'); + + // Run tests + const result1 = await testCreateTeacher(); + createdUserIds.push(result1.user_id); + + const result2 = await testCreateForeignTeacher(); + createdUserIds.push(result2.user_id); + + await testUpdateProfile(result2.user_id); + + const syncResult = await testSync(); + if (syncResult.success.length > 0) { + createdUserIds.push(syncResult.success[0].user_id); + } + + // Summary + console.log('\n' + '='.repeat(60)); + console.log('📊 TEST SUMMARY'); + console.log('='.repeat(60)); + console.log('✅ All tests passed!'); + console.log(`Created ${createdUserIds.length} test users`); + console.log('='.repeat(60)); + + // Cleanup + const readline = require('readline').createInterface({ + input: process.stdin, + output: process.stdout + }); + + readline.question('\nDelete test data? (y/n): ', async (answer) => { + if (answer.toLowerCase() === 'y') { + await cleanup(createdUserIds); + } else { + console.log('\n⚠️ Test data NOT deleted. User IDs:'); + createdUserIds.forEach((id, index) => { + console.log(` ${index + 1}. ${id}`); + }); + } + + readline.close(); + process.exit(0); + }); + + } catch (error) { + console.error('\n❌ Test failed:', error.message); + console.error(error.stack); + + // Cleanup on error + if (createdUserIds.length > 0) { + console.log('\nCleaning up partial test data...'); + await cleanup(createdUserIds); + } + + process.exit(1); + } +} + +// Run tests +main(); diff --git a/scripts/verify-schools.js b/scripts/verify-schools.js new file mode 100644 index 0000000..6a175f7 --- /dev/null +++ b/scripts/verify-schools.js @@ -0,0 +1,175 @@ +const { sequelize } = require('../config/database'); +const School = require('../models/School'); +const Class = require('../models/Class'); + +/** + * Script kiểm tra dữ liệu đã import + * Chạy: node src/scripts/verify-schools.js + */ + +async function verifyImportedData() { + try { + console.log('🔍 Kiểm tra dữ liệu đã import...\n'); + + // Kết nối database + await sequelize.authenticate(); + console.log('✅ Kết nối database thành công\n'); + + // Đếm số lượng trường + const schoolCount = await School.count(); + const activeSchools = await School.count({ where: { is_active: true } }); + + console.log('📊 THỐNG KÊ TRƯỜNG HỌC'); + console.log('='.repeat(60)); + console.log(`Tổng số trường: ${schoolCount}`); + console.log(`Trường đang hoạt động: ${activeSchools}`); + console.log(''); + + // Thống kê theo loại trường + const schoolTypes = await School.findAll({ + attributes: [ + 'school_type', + [sequelize.fn('COUNT', sequelize.col('id')), 'count'] + ], + group: ['school_type'] + }); + + console.log('📚 Phân loại trường:'); + schoolTypes.forEach(type => { + const typeName = { + 'preschool': 'Mầm non', + 'primary': 'Tiểu học', + 'secondary': 'Trung học cơ sở', + 'high_school': 'Trung học phổ thông' + }[type.school_type] || type.school_type; + + console.log(` ${typeName.padEnd(25)} ${type.get('count')}`); + }); + console.log(''); + + // Thống kê theo quận/huyện + const districts = await School.findAll({ + attributes: [ + 'district', + [sequelize.fn('COUNT', sequelize.col('id')), 'count'] + ], + group: ['district'], + order: [[sequelize.fn('COUNT', sequelize.col('id')), 'DESC']], + limit: 10 + }); + + console.log('🏙️ Top 10 Quận/Huyện có nhiều trường nhất:'); + districts.forEach((district, index) => { + console.log(` ${(index + 1 + '.').padEnd(4)} ${(district.district || 'N/A').padEnd(30)} ${district.get('count')}`); + }); + console.log(''); + + // Đếm số lượng lớp + const classCount = await Class.count(); + + console.log('📊 THỐNG KÊ LỚP HỌC'); + console.log('='.repeat(60)); + console.log(`Tổng số lớp: ${classCount}`); + console.log(''); + + // Thống kê lớp theo khối + const gradeStats = await Class.findAll({ + attributes: [ + 'grade_level', + [sequelize.fn('COUNT', sequelize.col('id')), 'count'] + ], + group: ['grade_level'], + order: ['grade_level'] + }); + + console.log('📝 Phân bổ lớp theo khối:'); + gradeStats.forEach(stat => { + const grade = stat.grade_level || 'N/A'; + console.log(` Khối ${grade}:`.padEnd(15) + `${stat.get('count')} lớp`); + }); + console.log(''); + + // Lấy 5 trường có nhiều lớp nhất + const topSchools = await sequelize.query(` + SELECT + s.school_name, + s.school_code, + COUNT(c.id) as class_count + FROM schools s + LEFT JOIN classes c ON s.id = c.school_id + GROUP BY s.id, s.school_name, s.school_code + ORDER BY class_count DESC + LIMIT 5 + `, { + type: sequelize.QueryTypes.SELECT + }); + + console.log('🏆 Top 5 trường có nhiều lớp nhất:'); + topSchools.forEach((school, index) => { + console.log(` ${index + 1}. ${school.school_name.padEnd(45)} ${school.class_count} lớp`); + }); + console.log(''); + + // Chi tiết một trường mẫu + const sampleSchool = await School.findOne({ + include: [{ + model: Class, + as: 'classes', + required: true + }], + order: [[sequelize.literal('(SELECT COUNT(*) FROM classes WHERE school_id = schools.id)'), 'DESC']] + }); + + if (sampleSchool) { + console.log('📖 CHI TIẾT MỘT TRƯỜNG MẪU'); + console.log('='.repeat(60)); + console.log(`Tên trường: ${sampleSchool.school_name}`); + console.log(`Mã trường: ${sampleSchool.school_code}`); + console.log(`Loại: ${sampleSchool.school_type}`); + console.log(`Địa chỉ: ${sampleSchool.address}`); + console.log(`Quận/Huyện: ${sampleSchool.district}`); + console.log(`Số điện thoại: ${sampleSchool.phone || 'N/A'}`); + + if (sampleSchool.classes && sampleSchool.classes.length > 0) { + console.log(`\nSố lớp: ${sampleSchool.classes.length}`); + console.log('\nDanh sách một số lớp:'); + sampleSchool.classes.slice(0, 10).forEach(cls => { + console.log(` - ${cls.class_name} (Khối ${cls.grade_level})`); + }); + if (sampleSchool.classes.length > 10) { + console.log(` ... và ${sampleSchool.classes.length - 10} lớp khác`); + } + } + } + + console.log('\n' + '='.repeat(60)); + console.log('✅ Kiểm tra hoàn tất!'); + console.log('='.repeat(60) + '\n'); + + } catch (error) { + console.error('❌ Lỗi khi kiểm tra dữ liệu:', error); + throw error; + } finally { + await sequelize.close(); + console.log('🔌 Đã đóng kết nối database'); + } +} + +// Setup associations +School.hasMany(Class, { foreignKey: 'school_id', as: 'classes' }); +Class.belongsTo(School, { foreignKey: 'school_id', as: 'school' }); + +// Chạy script +if (require.main === module) { + verifyImportedData() + .then(() => { + console.log('\n🎉 Script hoàn thành!'); + process.exit(0); + }) + .catch((error) => { + console.error('\n💥 Script thất bại:', error); + process.exit(1); + }); +} + +module.exports = { verifyImportedData }; diff --git a/server.js b/server.js new file mode 100644 index 0000000..892adc8 --- /dev/null +++ b/server.js @@ -0,0 +1,132 @@ +const { app, initializeApp } = require('./app'); +const { closeConnection } = require('./config/database'); +const { closeRedisConnection } = require('./config/redis'); +const { closeQueues } = require('./config/bullmq'); +const config = require('./config/config.json'); + +const PORT = config.server.port || 3000; +const HOST = '0.0.0.0'; + +let server; + +/** + * Start Server + */ +const startServer = async () => { + try { + // Initialize application (database, models, etc.) + await initializeApp(); + + // Start Express server + server = app.listen(PORT, HOST, () => { + console.log(''); + console.log('╔════════════════════════════════════════════════════════╗'); + console.log('║ 🚀 Sena School Management API Server Started ║'); + console.log('╠════════════════════════════════════════════════════════╣'); + console.log(`║ Environment: ${config.server.env.padEnd(38)} ║`); + console.log(`║ Server URL: http://${HOST}:${PORT}${' '.repeat(30 - HOST.length - PORT.toString().length)} ║`); + console.log(`║ API Base: http://${HOST}:${PORT}/api${' '.repeat(25 - HOST.length - PORT.toString().length)} ║`); + console.log(`║ Health: http://${HOST}:${PORT}/health${' '.repeat(22 - HOST.length - PORT.toString().length)} ║`); + console.log('╠════════════════════════════════════════════════════════╣'); + console.log('║ 📊 Services Status: ║'); + console.log('║ ✅ MySQL Database Connected (senaai.tech:11001) ║'); + console.log('║ ✅ Redis Sentinel Connected (senaai.tech) ║'); + console.log('║ ✅ BullMQ Queues Initialized ║'); + console.log('╚════════════════════════════════════════════════════════╝'); + console.log(''); + console.log('📝 Available Endpoints:'); + console.log(` GET /health - Health check`); + console.log(` GET /api - API information`); + console.log(` GET /api/schools - List schools`); + console.log(` POST /api/schools - Create school`); + console.log(` GET /api/queues/status - Queue metrics`); + console.log(` GET /api/cache/stats - Cache statistics`); + console.log(''); + console.log('🔧 To start the BullMQ worker:'); + console.log(' node workers/databaseWriteWorker.js'); + console.log(''); + }); + + // Handle server errors + server.on('error', (error) => { + console.error('❌ Server error:', error.message); + process.exit(1); + }); + + } catch (error) { + console.error('❌ Failed to start server:', error.message); + process.exit(1); + } +}; + +/** + * Graceful Shutdown + */ +const gracefulShutdown = async (signal) => { + console.log(''); + console.log(`⚠️ ${signal} received. Starting graceful shutdown...`); + + // Stop accepting new connections + if (server) { + server.close(async () => { + console.log('✅ HTTP server closed'); + + try { + // Close database connections + await closeConnection(); + + // Close Redis connection + await closeRedisConnection(); + + // Close BullMQ queues + await closeQueues(); + + console.log('✅ All connections closed gracefully'); + process.exit(0); + } catch (error) { + console.error('❌ Error during shutdown:', error.message); + process.exit(1); + } + }); + + // Force close after 10 seconds + setTimeout(() => { + console.error('⚠️ Forcing shutdown after timeout'); + process.exit(1); + }, 10000); + } else { + process.exit(0); + } +}; + +/** + * Handle Uncaught Exceptions + */ +process.on('uncaughtException', (error) => { + console.error('💥 UNCAUGHT EXCEPTION! Shutting down...'); + console.error(error.name, error.message); + console.error(error.stack); + process.exit(1); +}); + +/** + * Handle Unhandled Promise Rejections + */ +process.on('unhandledRejection', (error) => { + console.error('💥 UNHANDLED REJECTION! Shutting down...'); + console.error(error.name, error.message); + gracefulShutdown('UNHANDLED_REJECTION'); +}); + +/** + * Handle Termination Signals + */ +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); + +/** + * Start the server + */ +startServer(); + +module.exports = { server }; diff --git a/services/teacherProfileService.js b/services/teacherProfileService.js new file mode 100644 index 0000000..e4affae --- /dev/null +++ b/services/teacherProfileService.js @@ -0,0 +1,329 @@ +const { UsersAuth, UserProfile, TeacherDetail, School } = require('../models'); +const bcrypt = require('bcrypt'); +const { sequelize } = require('../config/database'); + +/** + * Teacher Profile Service + * Xử lý việc tạo user_profile, users_auth cho teacher + */ +class TeacherProfileService { + /** + * Tạo full user cho teacher mới (users_auth + user_profile + teacher_detail) + * @param {Object} teacherData - Dữ liệu teacher + * @returns {Promise} - Teacher đã tạo kèm profile + */ + async createTeacherWithProfile(teacherData) { + const transaction = await sequelize.transaction(); + + try { + const { + // Teacher specific + teacher_code, + teacher_type, + qualification, + specialization, + hire_date, + status = 'active', + skill_tags, + certifications, + + // User auth (optional - tự generate nếu không có) + username, + email, + password, + + // Profile (optional - có thể để trống) + full_name, + first_name, + last_name, + phone, + date_of_birth, + gender, + address, + school_id, + city, + district, + avatar_url, + } = teacherData; + + // Validate required fields + if (!teacher_code || !teacher_type) { + throw new Error('teacher_code and teacher_type are required'); + } + + // 1. Tạo users_auth + const generatedUsername = username || this._generateUsername(teacher_code, teacher_type); + const generatedEmail = email || this._generateEmail(teacher_code, teacher_type); + const generatedPassword = password || this._generateDefaultPassword(teacher_code); + + const salt = await bcrypt.genSalt(10); + const password_hash = await bcrypt.hash(generatedPassword, salt); + + const userAuth = await UsersAuth.create({ + username: generatedUsername, + email: generatedEmail, + password_hash: password_hash, + salt: salt, + is_active: true, + qr_version: 1, + qr_secret: this._generateQRSecret(), + }, { transaction }); + + // 2. Tạo user_profile với thông tin cơ bản + const profileData = { + user_id: userAuth.id, + full_name: full_name || this._generateFullNameFromCode(teacher_code), + first_name: first_name || '', + last_name: last_name || '', + phone: phone || '', + date_of_birth: date_of_birth || null, + gender: gender || null, + address: address || '', + school_id: school_id || null, + city: city || '', + district: district || '', + avatar_url: avatar_url || null, + etc: { + teacher_code: teacher_code, + created_from: 'teacher_import', + needs_profile_update: true, // Flag để user update sau + } + }; + + const userProfile = await UserProfile.create(profileData, { transaction }); + + // 3. Tạo teacher_detail + const teacherDetail = await TeacherDetail.create({ + user_id: userAuth.id, + teacher_code: teacher_code, + teacher_type: teacher_type, + qualification: qualification || '', + specialization: specialization || '', + hire_date: hire_date || new Date(), + status: status, + skill_tags: skill_tags || [], + certifications: certifications || [], + training_hours: 0, + last_training_date: null, + training_score_avg: null, + }, { transaction }); + + await transaction.commit(); + + // Return full data + return { + user_id: userAuth.id, + username: generatedUsername, + email: generatedEmail, + temporary_password: password ? null : generatedPassword, // Only return if auto-generated + profile: userProfile, + teacher_detail: teacherDetail, + }; + + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + /** + * Sync existing teachers: Tạo user_profile cho các teacher đã có sẵn + * @param {string} teacherId - Optional: sync specific teacher or all + * @returns {Promise} - Results + */ + async syncExistingTeachers(teacherId = null) { + const results = { + success: [], + failed: [], + skipped: [], + }; + + try { + // Get teachers without user_profile + const whereClause = teacherId ? { id: teacherId } : {}; + + const teachers = await TeacherDetail.findAll({ + where: whereClause, + include: [{ + model: UserProfile, + as: 'profile', + required: false, // LEFT JOIN to find teachers without profile + }], + }); + + for (const teacher of teachers) { + try { + // Skip if already has profile + if (teacher.profile) { + results.skipped.push({ + teacher_code: teacher.teacher_code, + reason: 'Already has profile', + }); + continue; + } + + // Check if user_id exists in users_auth + const userAuth = await UsersAuth.findByPk(teacher.user_id); + + if (!userAuth) { + results.failed.push({ + teacher_code: teacher.teacher_code, + reason: 'user_id not found in users_auth', + }); + continue; + } + + // Create user_profile + const userProfile = await UserProfile.create({ + user_id: teacher.user_id, + full_name: this._generateFullNameFromCode(teacher.teacher_code), + first_name: '', + last_name: '', + phone: '', + date_of_birth: null, + gender: null, + address: '', + school_id: null, + city: '', + district: '', + avatar_url: null, + etc: { + teacher_code: teacher.teacher_code, + teacher_type: teacher.teacher_type, + synced_from: 'sync_script', + needs_profile_update: true, + synced_at: new Date().toISOString(), + } + }); + + results.success.push({ + teacher_code: teacher.teacher_code, + user_id: teacher.user_id, + profile_id: userProfile.id, + }); + + } catch (error) { + results.failed.push({ + teacher_code: teacher.teacher_code, + error: error.message, + }); + } + } + + return results; + + } catch (error) { + throw new Error(`Sync failed: ${error.message}`); + } + } + + /** + * Update teacher profile (cho phép user tự update) + * @param {string} userId - User ID + * @param {Object} profileData - Data to update + * @returns {Promise} - Updated profile + */ + async updateTeacherProfile(userId, profileData) { + const transaction = await sequelize.transaction(); + + try { + const userProfile = await UserProfile.findOne({ + where: { user_id: userId }, + transaction, + }); + + if (!userProfile) { + throw new Error('User profile not found'); + } + + // Update allowed fields + const allowedFields = [ + 'full_name', 'first_name', 'last_name', + 'phone', 'date_of_birth', 'gender', + 'address', 'city', 'district', + 'avatar_url', + ]; + + const updateData = {}; + allowedFields.forEach(field => { + if (profileData[field] !== undefined) { + updateData[field] = profileData[field]; + } + }); + + // Update etc to remove needs_profile_update flag + if (Object.keys(updateData).length > 0) { + updateData.etc = { + ...userProfile.etc, + needs_profile_update: false, + last_updated: new Date().toISOString(), + }; + } + + await userProfile.update(updateData, { transaction }); + await transaction.commit(); + + return userProfile; + + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + // ============ Helper Methods ============ + + /** + * Generate username from teacher_code + */ + _generateUsername(teacher_code, teacher_type) { + const prefix = teacher_type === 'foreign' ? 'ft' : 'gv'; + return `${prefix}_${teacher_code.toLowerCase().replace(/[^a-z0-9]/g, '_')}`; + } + + /** + * Generate email from teacher_code + */ + _generateEmail(teacher_code, teacher_type) { + const username = this._generateUsername(teacher_code, teacher_type); + return `${username}@sena.edu.vn`; + } + + /** + * Generate default password (nên đổi sau lần đầu login) + */ + _generateDefaultPassword(teacher_code) { + return `Sena${teacher_code}@2026`; + } + + /** + * Generate QR secret for attendance + */ + _generateQRSecret() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let secret = ''; + for (let i = 0; i < 32; i++) { + secret += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return secret; + } + + /** + * Generate full_name from teacher_code + * VD: GV001 -> "Giáo viên GV001" + * FT001 -> "Teacher FT001" + */ + _generateFullNameFromCode(teacher_code) { + // Detect if foreign teacher + const isForeign = teacher_code.toUpperCase().startsWith('FT') || + teacher_code.toUpperCase().includes('FOREIGN'); + + if (isForeign) { + return `Teacher ${teacher_code}`; + } + + return `Giáo viên ${teacher_code}`; + } +} + +module.exports = new TeacherProfileService(); diff --git a/sync-database.js b/sync-database.js new file mode 100644 index 0000000..7f7af46 --- /dev/null +++ b/sync-database.js @@ -0,0 +1,40 @@ +/** + * Sync all models to database (create tables) + * Run once to initialize database schema + */ +const { sequelize } = require('./config/database'); +const { setupRelationships } = require('./models'); + +async function syncDatabase() { + try { + console.log('🔄 Starting database synchronization...'); + + // Test connection first + await sequelize.authenticate(); + console.log('✅ Database connection OK'); + + // Setup relationships + setupRelationships(); + console.log('✅ Model relationships configured'); + + // Sync all models (creates tables if not exist) + await sequelize.sync({ alter: false, force: false }); + console.log('✅ All models synced successfully'); + + // Show created tables + const [tables] = await sequelize.query('SHOW TABLES'); + console.log(`\n📊 Total tables: ${tables.length}`); + tables.forEach((table, index) => { + console.log(` ${index + 1}. ${Object.values(table)[0]}`); + }); + + console.log('\n✅ Database schema initialization complete!'); + process.exit(0); + } catch (error) { + console.error('❌ Error syncing database:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +syncDatabase(); diff --git a/workers/databaseWriteWorker.js b/workers/databaseWriteWorker.js new file mode 100644 index 0000000..766a604 --- /dev/null +++ b/workers/databaseWriteWorker.js @@ -0,0 +1,199 @@ +const { Worker } = require('bullmq'); +const { connectionOptions, QueueNames } = require('../config/bullmq'); +const models = require('../models'); +const { cacheUtils } = require('../config/redis'); + +/** + * Database Write Worker + * Xử lý các thao tác ghi database (create, update, delete) từ queue + */ +class DatabaseWriteWorker { + constructor() { + this.worker = new Worker( + QueueNames.DATABASE_WRITE, + async (job) => await this.processJob(job), + { + connection: connectionOptions, + prefix: process.env.BULLMQ_PREFIX || 'vcb', + concurrency: 5, // Process 5 jobs concurrently + limiter: { + max: 100, + duration: 1000, // 100 jobs per second max + }, + } + ); + + this.setupEventHandlers(); + } + + /** + * Process individual job + */ + async processJob(job) { + const { operation, model, data, userId } = job.data; + + console.log(`[Worker] Processing ${operation} ${model} - Job ID: ${job.id}`); + + try { + const Model = models[model]; + if (!Model) { + throw new Error(`Model ${model} not found`); + } + + let result; + + switch (operation) { + case 'create': + result = await this.handleCreate(Model, data, userId); + break; + + case 'update': + result = await this.handleUpdate(Model, data, userId); + break; + + case 'delete': + result = await this.handleDelete(Model, data, userId); + break; + + default: + throw new Error(`Unknown operation: ${operation}`); + } + + // Invalidate related caches + await this.invalidateCache(model, data.id); + + console.log(`[Worker] ✅ Completed ${operation} ${model} - Job ID: ${job.id}`); + return result; + + } catch (error) { + console.error(`[Worker] ❌ Error processing job ${job.id}:`, error.message); + throw error; // Will trigger retry + } + } + + /** + * Handle create operation + */ + async handleCreate(Model, data, userId) { + const record = await Model.create(data); + console.log(`[Worker] Created ${Model.name} with ID: ${record.id}`); + return record; + } + + /** + * Handle update operation + */ + async handleUpdate(Model, data, userId) { + const { id, ...updateData } = data; + + const [affectedCount] = await Model.update(updateData, { + where: { id }, + }); + + if (affectedCount === 0) { + throw new Error(`${Model.name} with ID ${id} not found`); + } + + console.log(`[Worker] Updated ${Model.name} with ID: ${id}`); + return { id, affectedCount }; + } + + /** + * Handle delete operation + */ + async handleDelete(Model, data, userId) { + const { id } = data; + + const affectedCount = await Model.destroy({ + where: { id }, + }); + + if (affectedCount === 0) { + throw new Error(`${Model.name} with ID ${id} not found`); + } + + console.log(`[Worker] Deleted ${Model.name} with ID: ${id}`); + return { id, affectedCount }; + } + + /** + * Invalidate related caches + */ + async invalidateCache(modelName, recordId) { + try { + // Specific record cache + if (recordId) { + await cacheUtils.delete(`${modelName.toLowerCase()}:${recordId}`); + } + + // List caches + await cacheUtils.deletePattern(`${modelName.toLowerCase()}s:list:*`); + await cacheUtils.deletePattern(`${modelName.toLowerCase()}:*:stats`); + + console.log(`[Worker] Cache invalidated for ${modelName}`); + } catch (error) { + console.error(`[Worker] Cache invalidation error:`, error.message); + } + } + + /** + * Setup event handlers + */ + setupEventHandlers() { + this.worker.on('completed', (job, result) => { + console.log(`[Worker] ✅ Job ${job.id} completed successfully`); + }); + + this.worker.on('failed', (job, error) => { + console.error(`[Worker] ❌ Job ${job?.id} failed:`, error.message); + }); + + this.worker.on('error', (error) => { + console.error('[Worker] ❌ Worker error:', error.message); + }); + + this.worker.on('stalled', (jobId) => { + console.warn(`[Worker] ⚠️ Job ${jobId} stalled`); + }); + } + + /** + * Graceful shutdown + */ + async close() { + console.log('[Worker] Closing database write worker...'); + await this.worker.close(); + console.log('[Worker] ✅ Database write worker closed'); + } +} + +/** + * Start worker + */ +const startWorker = () => { + const worker = new DatabaseWriteWorker(); + + // Handle process termination + process.on('SIGTERM', async () => { + console.log('SIGTERM received, closing worker...'); + await worker.close(); + process.exit(0); + }); + + process.on('SIGINT', async () => { + console.log('SIGINT received, closing worker...'); + await worker.close(); + process.exit(0); + }); + + console.log('✅ Database Write Worker started'); + return worker; +}; + +// Start worker if this file is run directly +if (require.main === module) { + require('dotenv').config(); + startWorker(); +} + +module.exports = { DatabaseWriteWorker, startWorker }; diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..9070392 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,4090 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.27.2": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.5.tgz#a8a4962e1567121ac0b3b487f52107443b455c7f" + integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== + +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.5.tgz#4c81b35e51e1b734f510c99b07dfbc7bbbb48f7e" + integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.5" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-module-transforms" "^7.28.3" + "@babel/helpers" "^7.28.4" + "@babel/parser" "^7.28.5" + "@babel/template" "^7.27.2" + "@babel/traverse" "^7.28.5" + "@babel/types" "^7.28.5" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.28.5", "@babel/generator@^7.7.2": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.5.tgz#712722d5e50f44d07bc7ac9fe84438742dd61298" + integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ== + dependencies: + "@babel/parser" "^7.28.5" + "@babel/types" "^7.28.5" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-compilation-targets@^7.27.2": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d" + integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== + dependencies: + "@babel/compat-data" "^7.27.2" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + +"@babel/helper-module-imports@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" + integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + +"@babel/helper-module-transforms@^7.28.3": + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz#a2b37d3da3b2344fe085dab234426f2b9a2fa5f6" + integrity sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/traverse" "^7.28.3" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.8.0": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" + integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.27.1", "@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + +"@babel/helpers@^7.28.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.4.tgz#fe07274742e95bdf7cf1443593eeb8926ab63827" + integrity sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w== + dependencies: + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.4" + +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.27.2", "@babel/parser@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08" + integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== + dependencies: + "@babel/types" "^7.28.5" + +"@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-bigint@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" + integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" + integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-class-static-block@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" + integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-import-attributes@^7.24.7": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz#34c017d54496f9b11b61474e7ea3dfd5563ffe07" + integrity sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-syntax-import-meta@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" + integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-jsx@^7.7.2": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz#2f9beb5eff30fa507c5532d107daac7b888fa34c" + integrity sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-private-property-in-object@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" + integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-top-level-await@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-typescript@^7.7.2": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18" + integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/template@^7.27.2", "@babel/template@^7.3.3": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" + integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/parser" "^7.27.2" + "@babel/types" "^7.27.1" + +"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.5.tgz#450cab9135d21a7a2ca9d2d35aa05c20e68c360b" + integrity sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.5" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.5" + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.5" + debug "^4.3.1" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.28.5", "@babel/types@^7.3.3": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b" + integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + +"@colors/colors@1.6.0", "@colors/colors@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" + integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== + +"@dabh/diagnostics@^2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.8.tgz#ead97e72ca312cf0e6dd7af0d300b58993a31a5e" + integrity sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q== + dependencies: + "@so-ric/colorspace" "^1.1.6" + enabled "2.0.x" + kuler "^2.0.0" + +"@eslint-community/eslint-utils@^4.8.0": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" + integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.12.1": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + +"@eslint/config-array@^0.21.1": + version "0.21.1" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713" + integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA== + dependencies: + "@eslint/object-schema" "^2.1.7" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/config-helpers@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz#1bd006ceeb7e2e55b2b773ab318d300e1a66aeda" + integrity sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== + dependencies: + "@eslint/core" "^0.17.0" + +"@eslint/core@^0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.17.0.tgz#77225820413d9617509da9342190a2019e78761c" + integrity sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/eslintrc@^3.3.1": + version "3.3.3" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.3.tgz#26393a0806501b5e2b6a43aa588a4d8df67880ac" + integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.1" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@9.39.2": + version "9.39.2" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.2.tgz#2d4b8ec4c3ea13c1b3748e0c97ecd766bdd80599" + integrity sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA== + +"@eslint/object-schema@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.7.tgz#6e2126a1347e86a4dedf8706ec67ff8e107ebbad" + integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== + +"@eslint/plugin-kit@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz#9779e3fd9b7ee33571a57435cf4335a1794a6cb2" + integrity sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== + dependencies: + "@eslint/core" "^0.17.0" + levn "^0.4.1" + +"@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" + integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== + +"@hapi/topo@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" + integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.7" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.4.0" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@ioredis/commands@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.4.0.tgz#9f657d51cdd5d2fdb8889592aa4a355546151f25" + integrity sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ== + +"@istanbuljs/load-nyc-config@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" + integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== + dependencies: + camelcase "^5.3.1" + find-up "^4.1.0" + get-package-type "^0.1.0" + js-yaml "^3.13.1" + resolve-from "^5.0.0" + +"@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + +"@jest/console@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.7.0.tgz#cd4822dbdb84529265c5a2bdb529a3c9cc950ffc" + integrity sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + slash "^3.0.0" + +"@jest/core@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.7.0.tgz#b6cccc239f30ff36609658c5a5e2291757ce448f" + integrity sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg== + dependencies: + "@jest/console" "^29.7.0" + "@jest/reporters" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + ansi-escapes "^4.2.1" + chalk "^4.0.0" + ci-info "^3.2.0" + exit "^0.1.2" + graceful-fs "^4.2.9" + jest-changed-files "^29.7.0" + jest-config "^29.7.0" + jest-haste-map "^29.7.0" + jest-message-util "^29.7.0" + jest-regex-util "^29.6.3" + jest-resolve "^29.7.0" + jest-resolve-dependencies "^29.7.0" + jest-runner "^29.7.0" + jest-runtime "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" + jest-watcher "^29.7.0" + micromatch "^4.0.4" + pretty-format "^29.7.0" + slash "^3.0.0" + strip-ansi "^6.0.0" + +"@jest/environment@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.7.0.tgz#24d61f54ff1f786f3cd4073b4b94416383baf2a7" + integrity sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw== + dependencies: + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-mock "^29.7.0" + +"@jest/expect-utils@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" + integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== + dependencies: + jest-get-type "^29.6.3" + +"@jest/expect@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.7.0.tgz#76a3edb0cb753b70dfbfe23283510d3d45432bf2" + integrity sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ== + dependencies: + expect "^29.7.0" + jest-snapshot "^29.7.0" + +"@jest/fake-timers@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz#fd91bf1fffb16d7d0d24a426ab1a47a49881a565" + integrity sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ== + dependencies: + "@jest/types" "^29.6.3" + "@sinonjs/fake-timers" "^10.0.2" + "@types/node" "*" + jest-message-util "^29.7.0" + jest-mock "^29.7.0" + jest-util "^29.7.0" + +"@jest/globals@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.7.0.tgz#8d9290f9ec47ff772607fa864ca1d5a2efae1d4d" + integrity sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/expect" "^29.7.0" + "@jest/types" "^29.6.3" + jest-mock "^29.7.0" + +"@jest/reporters@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.7.0.tgz#04b262ecb3b8faa83b0b3d321623972393e8f4c7" + integrity sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg== + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@jest/console" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@jridgewell/trace-mapping" "^0.3.18" + "@types/node" "*" + chalk "^4.0.0" + collect-v8-coverage "^1.0.0" + exit "^0.1.2" + glob "^7.1.3" + graceful-fs "^4.2.9" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-instrument "^6.0.0" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.0" + istanbul-reports "^3.1.3" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + jest-worker "^29.7.0" + slash "^3.0.0" + string-length "^4.0.1" + strip-ansi "^6.0.0" + v8-to-istanbul "^9.0.1" + +"@jest/schemas@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" + integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== + dependencies: + "@sinclair/typebox" "^0.27.8" + +"@jest/source-map@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.6.3.tgz#d90ba772095cf37a34a5eb9413f1b562a08554c4" + integrity sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw== + dependencies: + "@jridgewell/trace-mapping" "^0.3.18" + callsites "^3.0.0" + graceful-fs "^4.2.9" + +"@jest/test-result@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.7.0.tgz#8db9a80aa1a097bb2262572686734baed9b1657c" + integrity sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA== + dependencies: + "@jest/console" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/istanbul-lib-coverage" "^2.0.0" + collect-v8-coverage "^1.0.0" + +"@jest/test-sequencer@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz#6cef977ce1d39834a3aea887a1726628a6f072ce" + integrity sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw== + dependencies: + "@jest/test-result" "^29.7.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + slash "^3.0.0" + +"@jest/transform@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.7.0.tgz#df2dd9c346c7d7768b8a06639994640c642e284c" + integrity sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw== + dependencies: + "@babel/core" "^7.11.6" + "@jest/types" "^29.6.3" + "@jridgewell/trace-mapping" "^0.3.18" + babel-plugin-istanbul "^6.1.1" + chalk "^4.0.0" + convert-source-map "^2.0.0" + fast-json-stable-stringify "^2.1.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + jest-regex-util "^29.6.3" + jest-util "^29.7.0" + micromatch "^4.0.4" + pirates "^4.0.4" + slash "^3.0.0" + write-file-atomic "^4.0.2" + +"@jest/types@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" + integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== + dependencies: + "@jest/schemas" "^29.6.3" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@mapbox/node-pre-gyp@^1.0.11": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" + integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== + 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" + +"@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz#9edec61b22c3082018a79f6d1c30289ddf3d9d11" + integrity sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw== + +"@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz#33677a275204898ad8acbf62734fc4dc0b6a4855" + integrity sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw== + +"@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz#19edf7cdc2e7063ee328403c1d895a86dd28f4bb" + integrity sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg== + +"@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz#94fb0543ba2e28766c3fc439cabbe0440ae70159" + integrity sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw== + +"@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz#4a0609ab5fe44d07c9c60a11e4484d3c38bbd6e3" + integrity sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg== + +"@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz#0aa5502d547b57abfc4ac492de68e2006e417242" + integrity sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ== + +"@noble/hashes@^1.1.5": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@paralleldrive/cuid2@^2.2.2": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz#3d62ea9e7be867d3fa94b9897fab5b0ae187d784" + integrity sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw== + dependencies: + "@noble/hashes" "^1.1.5" + +"@sideway/address@^4.1.5": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" + integrity sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + +"@sinclair/typebox@^0.27.8": + version "0.27.8" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" + integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== + +"@sinonjs/commons@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^10.0.2": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" + integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== + dependencies: + "@sinonjs/commons" "^3.0.0" + +"@so-ric/colorspace@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@so-ric/colorspace/-/colorspace-1.1.6.tgz#62515d8b9f27746b76950a83bde1af812d91923b" + integrity sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw== + dependencies: + color "^5.0.2" + text-hex "1.0.x" + +"@types/babel__core@^7.1.14": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9" + integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74" + integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== + dependencies: + "@babel/types" "^7.28.2" + +"@types/debug@^4.1.8": + version "4.1.12" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + +"@types/estree@^1.0.6": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/graceful-fs@^4.1.3": + version "4.1.9" + resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" + integrity sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ== + dependencies: + "@types/node" "*" + +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" + integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== + +"@types/istanbul-lib-report@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz#53047614ae72e19fc0401d872de3ae2b4ce350bf" + integrity sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA== + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" + integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== + dependencies: + "@types/istanbul-lib-report" "*" + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/ms@*": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" + integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== + +"@types/node@*": + version "25.0.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.3.tgz#79b9ac8318f373fbfaaf6e2784893efa9701f269" + integrity sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA== + dependencies: + undici-types "~7.16.0" + +"@types/stack-utils@^2.0.0": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" + integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== + +"@types/triple-beam@^1.3.2": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" + integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== + +"@types/validator@^13.7.17": + version "13.15.10" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.15.10.tgz#742b77ec34d58554b94a76a14cef30d59e3c16b9" + integrity sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA== + +"@types/yargs-parser@*": + version "21.0.3" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" + integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== + +"@types/yargs@^17.0.8": + version "17.0.35" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.35.tgz#07013e46aa4d7d7d50a49e15604c1c5340d4eb24" + integrity sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg== + dependencies: + "@types/yargs-parser" "*" + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.15.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-escapes@^4.2.1: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +anymatch@^3.0.3, anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +"aproba@^1.0.3 || ^2.0.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.1.0.tgz#75500a190313d95c64e871e7e4284c6ac219f0b1" + integrity sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew== + +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +asap@^2.0.0: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + +async@^3.2.3: + version "3.2.6" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +aws-ssl-profiles@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641" + integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g== + +babel-jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" + integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg== + dependencies: + "@jest/transform" "^29.7.0" + "@types/babel__core" "^7.1.14" + babel-plugin-istanbul "^6.1.1" + babel-preset-jest "^29.6.3" + chalk "^4.0.0" + graceful-fs "^4.2.9" + slash "^3.0.0" + +babel-plugin-istanbul@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" + integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-instrument "^5.0.4" + test-exclude "^6.0.0" + +babel-plugin-jest-hoist@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz#aadbe943464182a8922c3c927c3067ff40d24626" + integrity sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg== + dependencies: + "@babel/template" "^7.3.3" + "@babel/types" "^7.3.3" + "@types/babel__core" "^7.1.14" + "@types/babel__traverse" "^7.0.6" + +babel-preset-current-node-syntax@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz#20730d6cdc7dda5d89401cab10ac6a32067acde6" + integrity sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg== + dependencies: + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-bigint" "^7.8.3" + "@babel/plugin-syntax-class-properties" "^7.12.13" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + "@babel/plugin-syntax-import-attributes" "^7.24.7" + "@babel/plugin-syntax-import-meta" "^7.10.4" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-top-level-await" "^7.14.5" + +babel-preset-jest@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz#fa05fa510e7d493896d7b0dd2033601c840f171c" + integrity sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA== + dependencies: + babel-plugin-jest-hoist "^29.6.3" + babel-preset-current-node-syntax "^1.0.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +baseline-browser-mapping@^2.9.0: + version "2.9.11" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz#53724708c8db5f97206517ecfe362dbe5181deea" + integrity sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ== + +basic-auth@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" + integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg== + dependencies: + safe-buffer "5.1.2" + +bcrypt@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.1.1.tgz#0f732c6dcb4e12e5b70a25e326a72965879ba6e2" + integrity sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.11" + node-addon-api "^5.0.0" + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +body-parser@~1.20.3: + version "1.20.4" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.4.tgz#f8e20f4d06ca8a50a71ed329c15dccad1cdc547f" + integrity sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA== + 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.14.0" + raw-body "~2.5.3" + type-is "~1.6.18" + unpipe "~1.0.0" + +brace-expansion@^1.1.7: + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^3.0.3, braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browserslist@^4.24.0: + version "4.28.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== + dependencies: + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" + +bser@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" + integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== + dependencies: + node-int64 "^0.4.0" + +buffer-equal-constant-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +bullmq@^5.1.0: + version "5.66.4" + resolved "https://registry.yarnpkg.com/bullmq/-/bullmq-5.66.4.tgz#879e4a361267c69c4abd86c52983338e50505dcb" + integrity sha512-y2VRk2z7d1YNI2JQDD7iThoD0X/0iZZ3VEp8lqT5s5U0XDl9CIjXp1LQgmE9EKy6ReHtzmYXS1f328PnUbZGtQ== + dependencies: + cron-parser "4.9.0" + ioredis "5.8.2" + msgpackr "1.11.5" + node-abort-controller "3.1.1" + semver "7.7.3" + tslib "2.8.1" + uuid "11.1.0" + +bytes@3.1.2, bytes@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +camelcase@^6.2.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +caniuse-lite@^1.0.30001759: + version "1.0.30001762" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz#e4dbfeda63d33258cdde93e53af2023a13ba27d4" + integrity sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw== + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +char-regex@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" + integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== + +chokidar@^3.5.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + 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" + optionalDependencies: + fsevents "~2.3.2" + +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + +ci-info@^3.2.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== + +cjs-module-lexer@^1.0.0: + version "1.4.3" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz#0f79731eb8cfe1ec72acd4066efac9d61991b00d" + integrity sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q== + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +cluster-key-slot@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== + +collect-v8-coverage@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz#cc1f01eb8d02298cbc9a437c74c70ab4e5210b80" + integrity sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw== + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-convert@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-3.1.3.tgz#db6627b97181cb8facdfce755ae26f97ab0711f1" + integrity sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg== + dependencies: + color-name "^2.0.0" + +color-name@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-2.1.0.tgz#0b677385c1c4b4edfdeaf77e38fa338e3a40b693" + integrity sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-string@^2.1.3: + version "2.1.4" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-2.1.4.tgz#9dcf566ff976e23368c8bd673f5c35103ab41058" + integrity sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg== + dependencies: + color-name "^2.0.0" + +color-support@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + +color@^5.0.2: + version "5.0.3" + resolved "https://registry.yarnpkg.com/color/-/color-5.0.3.tgz#f79390b1b778e222ffbb54304d3dbeaef633f97f" + integrity sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA== + dependencies: + color-convert "^3.1.3" + color-string "^2.1.3" + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +component-emitter@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17" + integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== + +compressible@~2.0.18: + version "2.0.18" + resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" + integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== + dependencies: + mime-db ">= 1.43.0 < 2" + +compression@^1.7.4: + version "1.8.1" + resolved "https://registry.yarnpkg.com/compression/-/compression-1.8.1.tgz#4a45d909ac16509195a9a28bd91094889c180d79" + integrity sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w== + dependencies: + bytes "3.1.2" + compressible "~2.0.18" + debug "2.6.9" + negotiator "~0.6.4" + on-headers "~1.1.0" + safe-buffer "5.2.1" + vary "~1.1.2" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +console-control-strings@^1.0.0, console-control-strings@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== + +content-disposition@~0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +cookie-signature@~1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.7.tgz#ab5dd7ab757c54e60f37ef6550f481c426d10454" + integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA== + +cookie@~0.7.1: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + +cookiejar@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + +cors@^2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +create-jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" + integrity sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q== + dependencies: + "@jest/types" "^29.6.3" + chalk "^4.0.0" + exit "^0.1.2" + graceful-fs "^4.2.9" + jest-config "^29.7.0" + jest-util "^29.7.0" + prompts "^2.0.1" + +cron-parser@4.9.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5" + integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q== + dependencies: + luxon "^3.2.1" + +cross-spawn@^7.0.3, cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4, debug@^4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.7: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +dedent@^1.0.0: + version "1.7.1" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.1.tgz#364661eea3d73f3faba7089214420ec2f8f13e15" + integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +deepmerge@^4.2.2: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== + +denque@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" + integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== + +depd@2.0.0, depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@1.2.0, destroy@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +detect-libc@^2.0.0, detect-libc@^2.0.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + +detect-newline@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" + integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== + +dezalgo@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" + integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + dependencies: + asap "^2.0.0" + wrappy "1" + +diff-sequences@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" + integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== + +dotenv@^16.3.1: + version "16.6.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020" + integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== + +dottie@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/dottie/-/dottie-2.0.6.tgz#34564ebfc6ec5e5772272d466424ad5b696484d4" + integrity sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA== + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +electron-to-chromium@^1.5.263: + version "1.5.267" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz#5d84f2df8cdb6bfe7e873706bb21bd4bfb574dc7" + integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw== + +emittery@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" + integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +enabled@2.0.x: + version "2.0.0" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" + integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== + +encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + +error-ex@^1.3.1: + version "1.3.4" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414" + integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== + dependencies: + is-arrayish "^0.2.1" + +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +escalade@^3.1.1, escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-scope@^8.4.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint@^9.17.0: + version "9.39.2" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.2.tgz#cb60e6d16ab234c0f8369a3fe7cc87967faf4b6c" + integrity sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.1" + "@eslint/config-array" "^0.21.1" + "@eslint/config-helpers" "^0.4.2" + "@eslint/core" "^0.17.0" + "@eslint/eslintrc" "^3.3.1" + "@eslint/js" "9.39.2" + "@eslint/plugin-kit" "^0.4.1" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" + esquery "^1.5.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^10.0.1, espree@^10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== + dependencies: + acorn "^8.15.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.1" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esquery@^1.5.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +execa@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +exit@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== + +expect@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" + integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== + dependencies: + "@jest/expect-utils" "^29.7.0" + jest-get-type "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + +express-async-errors@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/express-async-errors/-/express-async-errors-3.1.1.tgz#6053236d61d21ddef4892d6bd1d736889fc9da41" + integrity sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng== + +express-rate-limit@^7.1.5: + version "7.5.1" + resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-7.5.1.tgz#8c3a42f69209a3a1c969890070ece9e20a879dec" + integrity sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw== + +express@^4.18.2: + version "4.22.1" + resolved "https://registry.yarnpkg.com/express/-/express-4.22.1.tgz#1de23a09745a4fffdb39247b344bb5eaff382069" + integrity sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "~1.20.3" + 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.14.0" + 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" + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + +fb-watchman@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" + integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== + dependencies: + bser "2.1.1" + +fecha@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" + integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +finalhandler@~1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88" + integrity sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg== + 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" + +find-up@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.3.3" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + +fn.name@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" + integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== + +form-data@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + mime-types "^2.1.12" + +formidable@^3.5.4: + version "3.5.4" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.4.tgz#ac9a593b951e829b3298f21aa9a2243932f32ed9" + integrity sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug== + dependencies: + "@paralleldrive/cuid2" "^2.2.2" + dezalgo "^1.0.4" + once "^1.4.0" + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@~0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@^2.3.2, fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + 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" + +generate-function@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f" + integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ== + dependencies: + is-property "^1.0.2" + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + 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" + +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@^7.1.3, glob@^7.1.4: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + 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" + +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +graceful-fs@^4.2.9: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-symbols@^1.0.3, has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + +has-unicode@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +helmet@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/helmet/-/helmet-7.2.0.tgz#8b2dcc425b4a46c88f6953481b40453cbe66b167" + integrity sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw== + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +http-errors@~2.0.0, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +iconv-lite@^0.7.0: + version "0.7.1" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.1.tgz#d4af1d2092f2bb05aab6296e5e7cd286d2f15432" + integrity sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +iconv-lite@~0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ignore-by-default@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +import-fresh@^3.2.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-local@^3.0.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" + integrity sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== + dependencies: + pkg-dir "^4.2.0" + resolve-cwd "^3.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inflection@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.13.4.tgz#65aa696c4e2da6225b148d7a154c449366633a32" + integrity sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.3, inherits@~2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ioredis@5.8.2, ioredis@^5.3.2: + version "5.8.2" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.8.2.tgz#c7a228a26cf36f17a5a8011148836877780e2e14" + integrity sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q== + dependencies: + "@ioredis/commands" "1.4.0" + cluster-key-slot "^1.1.0" + debug "^4.3.4" + denque "^2.1.0" + lodash.defaults "^4.2.0" + lodash.isarguments "^3.1.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-generator-fn@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" + integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-property@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" + integrity sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g== + +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-instrument@^5.0.4: + version "5.2.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz#d10c8885c2125574e1c231cacadf955675e1ce3d" + integrity sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== + dependencies: + "@babel/core" "^7.12.3" + "@babel/parser" "^7.14.7" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.2.0" + semver "^6.3.0" + +istanbul-lib-instrument@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz#fa15401df6c15874bcb2105f773325d78c666765" + integrity sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q== + dependencies: + "@babel/core" "^7.23.9" + "@babel/parser" "^7.23.9" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-coverage "^3.2.0" + semver "^7.5.4" + +istanbul-lib-report@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" + integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + source-map "^0.6.1" + +istanbul-reports@^3.1.3: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +jest-changed-files@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz#1c06d07e77c78e1585d020424dedc10d6e17ac3a" + integrity sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w== + dependencies: + execa "^5.0.0" + jest-util "^29.7.0" + p-limit "^3.1.0" + +jest-circus@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.7.0.tgz#b6817a45fcc835d8b16d5962d0c026473ee3668a" + integrity sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/expect" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + co "^4.6.0" + dedent "^1.0.0" + is-generator-fn "^2.0.0" + jest-each "^29.7.0" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-runtime "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" + p-limit "^3.1.0" + pretty-format "^29.7.0" + pure-rand "^6.0.0" + slash "^3.0.0" + stack-utils "^2.0.3" + +jest-cli@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.7.0.tgz#5592c940798e0cae677eec169264f2d839a37995" + integrity sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg== + dependencies: + "@jest/core" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/types" "^29.6.3" + chalk "^4.0.0" + create-jest "^29.7.0" + exit "^0.1.2" + import-local "^3.0.2" + jest-config "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" + yargs "^17.3.1" + +jest-config@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.7.0.tgz#bcbda8806dbcc01b1e316a46bb74085a84b0245f" + integrity sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ== + dependencies: + "@babel/core" "^7.11.6" + "@jest/test-sequencer" "^29.7.0" + "@jest/types" "^29.6.3" + babel-jest "^29.7.0" + chalk "^4.0.0" + ci-info "^3.2.0" + deepmerge "^4.2.2" + glob "^7.1.3" + graceful-fs "^4.2.9" + jest-circus "^29.7.0" + jest-environment-node "^29.7.0" + jest-get-type "^29.6.3" + jest-regex-util "^29.6.3" + jest-resolve "^29.7.0" + jest-runner "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" + micromatch "^4.0.4" + parse-json "^5.2.0" + pretty-format "^29.7.0" + slash "^3.0.0" + strip-json-comments "^3.1.1" + +jest-diff@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" + integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.6.3" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + +jest-docblock@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.7.0.tgz#8fddb6adc3cdc955c93e2a87f61cfd350d5d119a" + integrity sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g== + dependencies: + detect-newline "^3.0.0" + +jest-each@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.7.0.tgz#162a9b3f2328bdd991beaabffbb74745e56577d1" + integrity sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ== + dependencies: + "@jest/types" "^29.6.3" + chalk "^4.0.0" + jest-get-type "^29.6.3" + jest-util "^29.7.0" + pretty-format "^29.7.0" + +jest-environment-node@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" + integrity sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-mock "^29.7.0" + jest-util "^29.7.0" + +jest-get-type@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" + integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== + +jest-haste-map@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.7.0.tgz#3c2396524482f5a0506376e6c858c3bbcc17b104" + integrity sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA== + dependencies: + "@jest/types" "^29.6.3" + "@types/graceful-fs" "^4.1.3" + "@types/node" "*" + anymatch "^3.0.3" + fb-watchman "^2.0.0" + graceful-fs "^4.2.9" + jest-regex-util "^29.6.3" + jest-util "^29.7.0" + jest-worker "^29.7.0" + micromatch "^4.0.4" + walker "^1.0.8" + optionalDependencies: + fsevents "^2.3.2" + +jest-leak-detector@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz#5b7ec0dadfdfec0ca383dc9aa016d36b5ea4c728" + integrity sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw== + dependencies: + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + +jest-matcher-utils@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" + integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== + dependencies: + chalk "^4.0.0" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + +jest-message-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" + integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.6.3" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.7.0" + slash "^3.0.0" + stack-utils "^2.0.3" + +jest-mock@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" + integrity sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-util "^29.7.0" + +jest-pnp-resolver@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" + integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== + +jest-regex-util@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz#4a556d9c776af68e1c5f48194f4d0327d24e8a52" + integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== + +jest-resolve-dependencies@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz#1b04f2c095f37fc776ff40803dc92921b1e88428" + integrity sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA== + dependencies: + jest-regex-util "^29.6.3" + jest-snapshot "^29.7.0" + +jest-resolve@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.7.0.tgz#64d6a8992dd26f635ab0c01e5eef4399c6bcbc30" + integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== + dependencies: + chalk "^4.0.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + jest-pnp-resolver "^1.2.2" + jest-util "^29.7.0" + jest-validate "^29.7.0" + resolve "^1.20.0" + resolve.exports "^2.0.0" + slash "^3.0.0" + +jest-runner@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.7.0.tgz#809af072d408a53dcfd2e849a4c976d3132f718e" + integrity sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ== + dependencies: + "@jest/console" "^29.7.0" + "@jest/environment" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + emittery "^0.13.1" + graceful-fs "^4.2.9" + jest-docblock "^29.7.0" + jest-environment-node "^29.7.0" + jest-haste-map "^29.7.0" + jest-leak-detector "^29.7.0" + jest-message-util "^29.7.0" + jest-resolve "^29.7.0" + jest-runtime "^29.7.0" + jest-util "^29.7.0" + jest-watcher "^29.7.0" + jest-worker "^29.7.0" + p-limit "^3.1.0" + source-map-support "0.5.13" + +jest-runtime@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.7.0.tgz#efecb3141cf7d3767a3a0cc8f7c9990587d3d817" + integrity sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/globals" "^29.7.0" + "@jest/source-map" "^29.6.3" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + cjs-module-lexer "^1.0.0" + collect-v8-coverage "^1.0.0" + glob "^7.1.3" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + jest-message-util "^29.7.0" + jest-mock "^29.7.0" + jest-regex-util "^29.6.3" + jest-resolve "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" + slash "^3.0.0" + strip-bom "^4.0.0" + +jest-snapshot@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz#c2c574c3f51865da1bb329036778a69bf88a6be5" + integrity sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw== + dependencies: + "@babel/core" "^7.11.6" + "@babel/generator" "^7.7.2" + "@babel/plugin-syntax-jsx" "^7.7.2" + "@babel/plugin-syntax-typescript" "^7.7.2" + "@babel/types" "^7.3.3" + "@jest/expect-utils" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + babel-preset-current-node-syntax "^1.0.0" + chalk "^4.0.0" + expect "^29.7.0" + graceful-fs "^4.2.9" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + natural-compare "^1.4.0" + pretty-format "^29.7.0" + semver "^7.5.3" + +jest-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" + integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + +jest-validate@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.7.0.tgz#7bf705511c64da591d46b15fce41400d52147d9c" + integrity sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw== + dependencies: + "@jest/types" "^29.6.3" + camelcase "^6.2.0" + chalk "^4.0.0" + jest-get-type "^29.6.3" + leven "^3.1.0" + pretty-format "^29.7.0" + +jest-watcher@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.7.0.tgz#7810d30d619c3a62093223ce6bb359ca1b28a2f2" + integrity sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g== + dependencies: + "@jest/test-result" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + ansi-escapes "^4.2.1" + chalk "^4.0.0" + emittery "^0.13.1" + jest-util "^29.7.0" + string-length "^4.0.1" + +jest-worker@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.7.0.tgz#acad073acbbaeb7262bd5389e1bcf43e10058d4a" + integrity sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== + dependencies: + "@types/node" "*" + jest-util "^29.7.0" + merge-stream "^2.0.0" + supports-color "^8.0.0" + +jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-29.7.0.tgz#994676fc24177f088f1c5e3737f5697204ff2613" + integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== + dependencies: + "@jest/core" "^29.7.0" + "@jest/types" "^29.6.3" + import-local "^3.0.2" + jest-cli "^29.7.0" + +joi@^17.11.0: + version "17.13.3" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.3.tgz#0f5cc1169c999b30d344366d384b12d92558bcec" + integrity sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA== + 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" + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^3.13.1: + version "3.14.2" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz#77485ce1dd7f33c061fd1b16ecea23b55fcb04b0" + integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + dependencies: + argparse "^2.0.1" + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +jsonwebtoken@^9.0.2: + version "9.0.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== + 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" + +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + +kuler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" + integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isarguments@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +logform@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.7.0.tgz#cfca97528ef290f2e125a08396805002b2d060d1" + integrity sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ== + 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" + +long@^5.2.1: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +lru.min@^1.0.0, lru.min@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.3.tgz#c8c3d001dfb4cbe5b8d1f4bea207d4a320e5d76f" + integrity sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q== + +luxon@^3.2.1: + version "3.7.2" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.7.2.tgz#d697e48f478553cca187a0f8436aff468e3ba0ba" + integrity sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew== + +make-dir@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + +makeerror@1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" + integrity sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== + dependencies: + tmpl "1.0.5" + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +merge-descriptors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +methods@^1.1.2, methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +micromatch@^4.0.4: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +"mime-db@>= 1.43.0 < 2": + version "1.54.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + +mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +mime@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minipass@^3.0.0: + version "3.3.6" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" + integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== + dependencies: + yallist "^4.0.0" + +minipass@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" + integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== + +minizlib@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + +mkdirp@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +moment-timezone@^0.5.43: + version "0.5.48" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.48.tgz#111727bb274734a518ae154b5ca589283f058967" + integrity sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw== + dependencies: + moment "^2.29.4" + +moment@^2.29.4: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + +morgan@^1.10.0: + version "1.10.1" + resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.1.tgz#4e02e6a4465a48e26af540191593955d17f61570" + integrity sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A== + dependencies: + basic-auth "~2.0.1" + debug "2.6.9" + depd "~2.0.0" + on-finished "~2.3.0" + on-headers "~1.1.0" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +msgpackr-extract@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz#e9d87023de39ce714872f9e9504e3c1996d61012" + integrity sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA== + dependencies: + node-gyp-build-optional-packages "5.2.2" + optionalDependencies: + "@msgpackr-extract/msgpackr-extract-darwin-arm64" "3.0.3" + "@msgpackr-extract/msgpackr-extract-darwin-x64" "3.0.3" + "@msgpackr-extract/msgpackr-extract-linux-arm" "3.0.3" + "@msgpackr-extract/msgpackr-extract-linux-arm64" "3.0.3" + "@msgpackr-extract/msgpackr-extract-linux-x64" "3.0.3" + "@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.3" + +msgpackr@1.11.5: + version "1.11.5" + resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.11.5.tgz#edf0b9d9cb7d8ed6897dd0e42cfb865a2f4b602e" + integrity sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA== + optionalDependencies: + msgpackr-extract "^3.0.2" + +mysql2@^3.6.5: + version "3.16.0" + resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.16.0.tgz#f296336b3ddba00fe061c7ca7ada2ddf8689c17e" + integrity sha512-AEGW7QLLSuSnjCS4pk3EIqOmogegmze9h8EyrndavUQnIUcfkVal/sK7QznE+a3bc6rzPbAiui9Jcb+96tPwYA== + dependencies: + aws-ssl-profiles "^1.1.1" + denque "^2.1.0" + generate-function "^2.3.1" + iconv-lite "^0.7.0" + long "^5.2.1" + lru.min "^1.0.0" + named-placeholders "^1.1.3" + seq-queue "^0.0.5" + sqlstring "^2.3.2" + +named-placeholders@^1.1.3: + version "1.1.6" + resolved "https://registry.yarnpkg.com/named-placeholders/-/named-placeholders-1.1.6.tgz#c50c6920b43f258f59c16add1e56654f5cc02bb5" + integrity sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w== + dependencies: + lru.min "^1.1.0" + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +negotiator@~0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7" + integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== + +node-abort-controller@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" + integrity sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ== + +node-addon-api@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" + integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== + +node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +node-gyp-build-optional-packages@5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz#522f50c2d53134d7f3a76cd7255de4ab6c96a3a4" + integrity sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw== + dependencies: + detect-libc "^2.0.1" + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== + +node-releases@^2.0.27: + version "2.0.27" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" + integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== + +nodemon@^3.0.2: + version "3.1.11" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.11.tgz#04a54d1e794fbec9d8f6ffd8bf1ba9ea93a756ed" + integrity sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g== + dependencies: + chokidar "^3.5.2" + debug "^4" + ignore-by-default "^1.0.1" + minimatch "^3.1.2" + 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" + +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + +object-assign@^4, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.3: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== + dependencies: + ee-first "1.1.1" + +on-finished@~2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +on-headers@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.1.0.tgz#59da4f91c45f5f989c6e4bcedc5a3b0aed70ff65" + integrity sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A== + +once@^1.3.0, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +one-time@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" + integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== + dependencies: + fn.name "1.x.x" + +onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2, p-limit@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-to-regexp@~0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" + integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== + +pg-connection-string@^2.6.1: + version "2.9.1" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.9.1.tgz#bb1fd0011e2eb76ac17360dc8fa183b2d3465238" + integrity sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pirates@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== + +pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== + dependencies: + "@jest/schemas" "^29.6.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" + +prompts@^2.0.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +pstree.remy@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" + integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +pure-rand@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" + integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== + +qs@^6.11.2, qs@~6.14.0: + version "6.14.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.1.tgz#a41d85b9d3902f31d27861790506294881871159" + integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ== + dependencies: + side-channel "^1.1.0" + +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@~2.5.3: + version "2.5.3" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2" + integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.4.24" + unpipe "~1.0.0" + +react-is@^18.0.0: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + +readable-stream@^3.4.0, readable-stream@^3.6.0, readable-stream@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== + dependencies: + redis-errors "^1.0.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + dependencies: + resolve-from "^5.0.0" + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve.exports@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.3.tgz#41955e6f1b4013b7586f873749a635dea07ebe3f" + integrity sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A== + +resolve@^1.20.0: + version "1.22.11" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" + integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== + dependencies: + is-core-module "^2.16.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +retry-as-promised@^7.0.4: + version "7.1.1" + resolved "https://registry.yarnpkg.com/retry-as-promised/-/retry-as-promised-7.1.1.tgz#3626246f04c1941ff10cebcfa3df0577fd8ab2d7" + integrity sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +safe-buffer@5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-stable-stringify@^2.3.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" + integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +semver@7.7.3, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + +semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +send@~0.19.0, send@~0.19.1: + version "0.19.2" + resolved "https://registry.yarnpkg.com/send/-/send-0.19.2.tgz#59bc0da1b4ea7ad42736fd642b1c4294e114ff29" + integrity sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg== + 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" + +seq-queue@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/seq-queue/-/seq-queue-0.0.5.tgz#d56812e1c017a6e4e7c3e3a37a1da6d78dd3c93e" + integrity sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q== + +sequelize-pool@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/sequelize-pool/-/sequelize-pool-7.1.0.tgz#210b391af4002762f823188fd6ecfc7413020768" + integrity sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg== + +sequelize@^6.35.2: + version "6.37.7" + resolved "https://registry.yarnpkg.com/sequelize/-/sequelize-6.37.7.tgz#55a6f8555ae76c1fbd4bce76b2ac5fcc0a1e6eb6" + integrity sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA== + dependencies: + "@types/debug" "^4.1.8" + "@types/validator" "^13.7.17" + debug "^4.3.4" + dottie "^2.0.6" + inflection "^1.13.4" + lodash "^4.17.21" + moment "^2.29.4" + moment-timezone "^0.5.43" + pg-connection-string "^2.6.1" + retry-as-promised "^7.0.4" + semver "^7.5.4" + sequelize-pool "^7.1.0" + toposort-class "^1.0.1" + uuid "^8.3.2" + validator "^13.9.0" + wkx "^0.5.0" + +serve-static@~1.16.2: + version "1.16.3" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.3.tgz#a97b74d955778583f3862a4f0b841eb4d5d78cf9" + integrity sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA== + dependencies: + encodeurl "~2.0.0" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "~0.19.1" + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + +setprototypeof@1.2.0, setprototypeof@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + 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" + +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +signal-exit@^3.0.0, signal-exit@^3.0.3, signal-exit@^3.0.7: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +simple-update-notifier@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" + integrity sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w== + dependencies: + semver "^7.5.3" + +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +source-map-support@0.5.13: + version "0.5.13" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" + integrity sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0, source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +sqlstring@^2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.3.tgz#2ddc21f03bce2c387ed60680e739922c65751d0c" + integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg== + +stack-trace@0.0.x: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== + +stack-utils@^2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== + dependencies: + escape-string-regexp "^2.0.0" + +standard-as-callback@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + +statuses@~2.0.1, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + +string-length@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" + integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== + dependencies: + char-regex "^1.0.2" + strip-ansi "^6.0.0" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-bom@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" + integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +superagent@^10.2.3: + version "10.2.3" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-10.2.3.tgz#d1e4986f2caac423c37e38077f9073ccfe73a59b" + integrity sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig== + dependencies: + component-emitter "^1.3.1" + cookiejar "^2.1.4" + debug "^4.3.7" + fast-safe-stringify "^2.1.1" + form-data "^4.0.4" + formidable "^3.5.4" + methods "^1.1.2" + mime "2.6.0" + qs "^6.11.2" + +supertest@^7.1.3: + version "7.1.4" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-7.1.4.tgz#3175e2539f517ca72fdc7992ffff35b94aca7d34" + integrity sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg== + dependencies: + methods "^1.1.2" + superagent "^10.2.3" + +supports-color@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +tar@^6.1.11: + version "6.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" + integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== + 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" + +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" + integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + +text-hex@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" + integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== + +tmpl@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toidentifier@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +toposort-class@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toposort-class/-/toposort-class-1.0.1.tgz#7ffd1f78c8be28c3ba45cd4e1a3f5ee193bd9988" + integrity sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg== + +touch@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" + integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +triple-beam@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984" + integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== + +tslib@2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +undefsafe@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" + integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== + +undici-types@~7.16.0: + version "7.16.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" + integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== + +unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +update-browserslist-db@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +uuid@11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" + integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== + +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +v8-to-istanbul@^9.0.1: + version "9.3.0" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175" + integrity sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.12" + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^2.0.0" + +validator@^13.9.0: + version "13.15.26" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.15.26.tgz#36c3deeab30e97806a658728a155c66fcaa5b944" + integrity sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA== + +vary@^1, vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +walker@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" + integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== + dependencies: + makeerror "1.0.12" + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wide-align@^1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" + integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== + dependencies: + string-width "^1.0.2 || 2 || 3 || 4" + +winston-transport@^4.9.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.9.0.tgz#3bba345de10297654ea6f33519424560003b3bf9" + integrity sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A== + dependencies: + logform "^2.7.0" + readable-stream "^3.6.2" + triple-beam "^1.3.0" + +winston@^3.11.0: + version "3.19.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.19.0.tgz#cc1d1262f5f45946904085cfffe73efb4b7a581d" + integrity sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA== + 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" + +wkx@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/wkx/-/wkx-0.5.0.tgz#c6c37019acf40e517cc6b94657a25a3d4aa33e8c" + integrity sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg== + dependencies: + "@types/node" "*" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +write-file-atomic@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" + integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== + dependencies: + imurmurhash "^0.1.4" + signal-exit "^3.0.7" + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.3.1: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==