update
This commit is contained in:
344
LOGIN_GUIDE.md
Normal file
344
LOGIN_GUIDE.md
Normal file
@@ -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** 🚀
|
||||||
244
app.js
Normal file
244
app.js
Normal file
@@ -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 };
|
||||||
256
config/bullmq.js
Normal file
256
config/bullmq.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
40
config/config.json
Normal file
40
config/config.json
Normal file
@@ -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": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
98
config/database.js
Normal file
98
config/database.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
169
config/redis.js
Normal file
169
config/redis.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
270
controllers/academicYearController.js
Normal file
270
controllers/academicYearController.js
Normal file
@@ -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();
|
||||||
248
controllers/attendanceController.js
Normal file
248
controllers/attendanceController.js
Normal file
@@ -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();
|
||||||
351
controllers/authController.js
Normal file
351
controllers/authController.js
Normal file
@@ -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();
|
||||||
335
controllers/chapterController.js
Normal file
335
controllers/chapterController.js
Normal file
@@ -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();
|
||||||
364
controllers/classController.js
Normal file
364
controllers/classController.js
Normal file
@@ -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();
|
||||||
374
controllers/gameController.js
Normal file
374
controllers/gameController.js
Normal file
@@ -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();
|
||||||
264
controllers/gradeController.js
Normal file
264
controllers/gradeController.js
Normal file
@@ -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();
|
||||||
391
controllers/parentTaskController.js
Normal file
391
controllers/parentTaskController.js
Normal file
@@ -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();
|
||||||
219
controllers/roomController.js
Normal file
219
controllers/roomController.js
Normal file
@@ -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();
|
||||||
302
controllers/schoolController.js
Normal file
302
controllers/schoolController.js
Normal file
@@ -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();
|
||||||
211
controllers/studentController.js
Normal file
211
controllers/studentController.js
Normal file
@@ -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();
|
||||||
343
controllers/subjectController.js
Normal file
343
controllers/subjectController.js
Normal file
@@ -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();
|
||||||
253
controllers/subscriptionController.js
Normal file
253
controllers/subscriptionController.js
Normal file
@@ -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();
|
||||||
274
controllers/teacherController.js
Normal file
274
controllers/teacherController.js
Normal file
@@ -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();
|
||||||
318
controllers/trainingController.js
Normal file
318
controllers/trainingController.js
Normal file
@@ -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();
|
||||||
495
controllers/userController.js
Normal file
495
controllers/userController.js
Normal file
@@ -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();
|
||||||
144
middleware/errorHandler.js
Normal file
144
middleware/errorHandler.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
19
models/AcademicYear.js
Normal file
19
models/AcademicYear.js
Normal file
@@ -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;
|
||||||
22
models/AttendanceDaily.js
Normal file
22
models/AttendanceDaily.js
Normal file
@@ -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;
|
||||||
90
models/AttendanceLog.js
Normal file
90
models/AttendanceLog.js
Normal file
@@ -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;
|
||||||
35
models/AuditLog.js
Normal file
35
models/AuditLog.js
Normal file
@@ -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;
|
||||||
69
models/Chapter.js
Normal file
69
models/Chapter.js
Normal file
@@ -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;
|
||||||
22
models/Class.js
Normal file
22
models/Class.js
Normal file
@@ -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;
|
||||||
22
models/ClassSchedule.js
Normal file
22
models/ClassSchedule.js
Normal file
@@ -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;
|
||||||
101
models/Game.js
Normal file
101
models/Game.js
Normal file
@@ -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;
|
||||||
100
models/Grade.js
Normal file
100
models/Grade.js
Normal file
@@ -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;
|
||||||
19
models/GradeCategory.js
Normal file
19
models/GradeCategory.js
Normal file
@@ -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;
|
||||||
21
models/GradeHistory.js
Normal file
21
models/GradeHistory.js
Normal file
@@ -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;
|
||||||
29
models/GradeItem.js
Normal file
29
models/GradeItem.js
Normal file
@@ -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;
|
||||||
20
models/GradeSummary.js
Normal file
20
models/GradeSummary.js
Normal file
@@ -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;
|
||||||
22
models/LeaveRequest.js
Normal file
22
models/LeaveRequest.js
Normal file
@@ -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;
|
||||||
101
models/Lesson.js
Normal file
101
models/Lesson.js
Normal file
@@ -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;
|
||||||
36
models/Message.js
Normal file
36
models/Message.js
Normal file
@@ -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;
|
||||||
40
models/Notification.js
Normal file
40
models/Notification.js
Normal file
@@ -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;
|
||||||
33
models/NotificationLog.js
Normal file
33
models/NotificationLog.js
Normal file
@@ -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;
|
||||||
95
models/ParentAssignedTask.js
Normal file
95
models/ParentAssignedTask.js
Normal file
@@ -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;
|
||||||
24
models/ParentStudentMap.js
Normal file
24
models/ParentStudentMap.js
Normal file
@@ -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;
|
||||||
24
models/Permission.js
Normal file
24
models/Permission.js
Normal file
@@ -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;
|
||||||
64
models/Role.js
Normal file
64
models/Role.js
Normal file
@@ -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;
|
||||||
18
models/RolePermission.js
Normal file
18
models/RolePermission.js
Normal file
@@ -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;
|
||||||
20
models/Room.js
Normal file
20
models/Room.js
Normal file
@@ -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;
|
||||||
83
models/School.js
Normal file
83
models/School.js
Normal file
@@ -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;
|
||||||
92
models/StaffAchievement.js
Normal file
92
models/StaffAchievement.js
Normal file
@@ -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;
|
||||||
21
models/StaffContract.js
Normal file
21
models/StaffContract.js
Normal file
@@ -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;
|
||||||
90
models/StaffTrainingAssignment.js
Normal file
90
models/StaffTrainingAssignment.js
Normal file
@@ -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;
|
||||||
24
models/StudentDetail.js
Normal file
24
models/StudentDetail.js
Normal file
@@ -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;
|
||||||
29
models/Subject.js
Normal file
29
models/Subject.js
Normal file
@@ -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;
|
||||||
69
models/SubscriptionPlan.js
Normal file
69
models/SubscriptionPlan.js
Normal file
@@ -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;
|
||||||
31
models/TeacherDetail.js
Normal file
31
models/TeacherDetail.js
Normal file
@@ -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;
|
||||||
27
models/UserAssignment.js
Normal file
27
models/UserAssignment.js
Normal file
@@ -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;
|
||||||
79
models/UserProfile.js
Normal file
79
models/UserProfile.js
Normal file
@@ -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;
|
||||||
87
models/UserSubscription.js
Normal file
87
models/UserSubscription.js
Normal file
@@ -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;
|
||||||
112
models/UsersAuth.js
Normal file
112
models/UsersAuth.js
Normal file
@@ -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;
|
||||||
271
models/index.js
Normal file
271
models/index.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
49
package.json
Normal file
49
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
252
public/js/login.js
Normal file
252
public/js/login.js
Normal file
@@ -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 = `
|
||||||
|
<div class="response-content">
|
||||||
|
<div class="response-title">${title}</div>
|
||||||
|
<span class="status ${statusClass}">Status: ${status} ${statusText}</span>
|
||||||
|
<pre>${JSON.stringify(data, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to show loading
|
||||||
|
function showLoading(btn) {
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = originalText + '<span class="loading"></span>';
|
||||||
|
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);
|
||||||
|
});
|
||||||
290
public/login.html
Normal file
290
public/login.html
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sena DB API - Login Test</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 1000px;
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-section {
|
||||||
|
padding: 50px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-section {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 50px 40px;
|
||||||
|
border-left: 1px solid #e0e0e0;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 15px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #f8f8f2;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-info {
|
||||||
|
background: #e7f3ff;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #004085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-info code {
|
||||||
|
background: white;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-section {
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-data {
|
||||||
|
background: #fff3cd;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Login Section -->
|
||||||
|
<div class="login-section">
|
||||||
|
<h2>🔐 Login Test</h2>
|
||||||
|
<p class="subtitle">Test quy trình đăng nhập API</p>
|
||||||
|
|
||||||
|
<div class="endpoint-info">
|
||||||
|
<strong>Endpoints:</strong><br>
|
||||||
|
POST <code>/api/auth/login</code> - Đăng nhập<br>
|
||||||
|
POST <code>/api/auth/register</code> - Đăng ký<br>
|
||||||
|
POST <code>/api/auth/verify-token</code> - Verify token<br>
|
||||||
|
POST <code>/api/auth/logout</code> - Đăng xuất<br>
|
||||||
|
GET <code>/api/users</code> - Danh sách users
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="example-data">
|
||||||
|
<strong>✅ API sẵn sàng!</strong> Hệ thống đã có đầy đủ các endpoint: login, register, verify-token, logout.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username hoặc Email:</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="Nhập username hoặc email" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="Nhập mật khẩu" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn" id="loginBtn">
|
||||||
|
Đăng nhập
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary" id="registerBtn">
|
||||||
|
Đăng ký tài khoản mới
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary" id="verifyTokenBtn">
|
||||||
|
Verify Token
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary" id="logoutBtn">
|
||||||
|
Đăng xuất
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary" id="getAllUsersBtn">
|
||||||
|
Lấy danh sách Users
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary" id="testHealthBtn">
|
||||||
|
Test Health Endpoint
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Response Section -->
|
||||||
|
<div class="response-section">
|
||||||
|
<h2>📊 Kết quả</h2>
|
||||||
|
<p class="subtitle">Response từ API server</p>
|
||||||
|
|
||||||
|
<div id="responseContainer">
|
||||||
|
<div class="response-content">
|
||||||
|
<p style="color: #666; text-align: center;">Chưa có request nào được gửi</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
routes/academicYearRoutes.js
Normal file
33
routes/academicYearRoutes.js
Normal file
@@ -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;
|
||||||
27
routes/attendanceRoutes.js
Normal file
27
routes/attendanceRoutes.js
Normal file
@@ -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;
|
||||||
24
routes/authRoutes.js
Normal file
24
routes/authRoutes.js
Normal file
@@ -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;
|
||||||
28
routes/chapterRoutes.js
Normal file
28
routes/chapterRoutes.js
Normal file
@@ -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;
|
||||||
33
routes/classRoutes.js
Normal file
33
routes/classRoutes.js
Normal file
@@ -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;
|
||||||
34
routes/gameRoutes.js
Normal file
34
routes/gameRoutes.js
Normal file
@@ -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;
|
||||||
33
routes/gradeRoutes.js
Normal file
33
routes/gradeRoutes.js
Normal file
@@ -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;
|
||||||
30
routes/parentTaskRoutes.js
Normal file
30
routes/parentTaskRoutes.js
Normal file
@@ -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;
|
||||||
30
routes/roomRoutes.js
Normal file
30
routes/roomRoutes.js
Normal file
@@ -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;
|
||||||
73
routes/schoolRoutes.js
Normal file
73
routes/schoolRoutes.js
Normal file
@@ -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;
|
||||||
27
routes/studentRoutes.js
Normal file
27
routes/studentRoutes.js
Normal file
@@ -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;
|
||||||
36
routes/subjectRoutes.js
Normal file
36
routes/subjectRoutes.js
Normal file
@@ -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;
|
||||||
24
routes/subscriptionRoutes.js
Normal file
24
routes/subscriptionRoutes.js
Normal file
@@ -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;
|
||||||
30
routes/teacherRoutes.js
Normal file
30
routes/teacherRoutes.js
Normal file
@@ -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;
|
||||||
27
routes/trainingRoutes.js
Normal file
27
routes/trainingRoutes.js
Normal file
@@ -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;
|
||||||
41
routes/userRoutes.js
Normal file
41
routes/userRoutes.js
Normal file
@@ -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;
|
||||||
79
scripts/add-senaai-center.js
Normal file
79
scripts/add-senaai-center.js
Normal file
@@ -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 };
|
||||||
50
scripts/clear-teacher-cache.js
Normal file
50
scripts/clear-teacher-cache.js
Normal file
@@ -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();
|
||||||
111
scripts/create-test-user.js
Normal file
111
scripts/create-test-user.js
Normal file
@@ -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();
|
||||||
22
scripts/drop-grade-tables.js
Normal file
22
scripts/drop-grade-tables.js
Normal file
@@ -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();
|
||||||
267
scripts/dump-and-sync-teachers.js
Normal file
267
scripts/dump-and-sync-teachers.js
Normal file
@@ -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();
|
||||||
193
scripts/export-teachers-info.js
Normal file
193
scripts/export-teachers-info.js
Normal file
@@ -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();
|
||||||
305
scripts/generate-models.js
Normal file
305
scripts/generate-models.js
Normal file
@@ -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(', '));
|
||||||
315
scripts/import-apdinh-students.js
Normal file
315
scripts/import-apdinh-students.js
Normal file
@@ -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 };
|
||||||
201
scripts/import-foreign-teachers.js
Normal file
201
scripts/import-foreign-teachers.js
Normal file
@@ -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 };
|
||||||
183
scripts/import-schools.js
Normal file
183
scripts/import-schools.js
Normal file
@@ -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 };
|
||||||
67
scripts/sync-database.js
Normal file
67
scripts/sync-database.js
Normal file
@@ -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();
|
||||||
91
scripts/sync-teacher-profiles.js
Normal file
91
scripts/sync-teacher-profiles.js
Normal file
@@ -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();
|
||||||
58
scripts/sync-user-tables.js
Normal file
58
scripts/sync-user-tables.js
Normal file
@@ -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 };
|
||||||
76
scripts/test-api-teachers.js
Normal file
76
scripts/test-api-teachers.js
Normal file
@@ -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();
|
||||||
85
scripts/test-profile-logic.js
Normal file
85
scripts/test-profile-logic.js
Normal file
@@ -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();
|
||||||
105
scripts/test-student-profile-join.js
Normal file
105
scripts/test-student-profile-join.js
Normal file
@@ -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();
|
||||||
267
scripts/test-teacher-profile.js
Normal file
267
scripts/test-teacher-profile.js
Normal file
@@ -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();
|
||||||
175
scripts/verify-schools.js
Normal file
175
scripts/verify-schools.js
Normal file
@@ -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 };
|
||||||
132
server.js
Normal file
132
server.js
Normal file
@@ -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 };
|
||||||
329
services/teacherProfileService.js
Normal file
329
services/teacherProfileService.js
Normal file
@@ -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<Object>} - 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<Object>} - 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<Object>} - 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();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user