diff --git a/LOGIN_GUIDE.md b/LOGIN_GUIDE.md
new file mode 100644
index 0000000..7d57f74
--- /dev/null
+++ b/LOGIN_GUIDE.md
@@ -0,0 +1,344 @@
+# 🔐 Hướng Dẫn Hệ Thống Đăng Nhập
+
+## 📋 Mô Tả Quy Trình Đăng Nhập
+
+### 1️⃣ Kiến Trúc Hệ Thống
+
+```
+┌─────────────┐ ┌──────────────┐ ┌──────────────┐
+│ Browser │────▶│ API Server │────▶│ Database │
+│ (login.html)│◀────│ (Express) │◀────│ (MySQL) │
+└─────────────┘ └──────────────┘ └──────────────┘
+ │
+ ▼
+ ┌──────────────┐
+ │ Redis │
+ │ (Cache) │
+ └──────────────┘
+```
+
+### 2️⃣ Quy Trình Đăng Nhập Chi Tiết
+
+#### **Bước 1: User Nhập Thông Tin**
+- Frontend (login.html) hiển thị form đăng nhập
+- User nhập `username` hoặc `email` và `password`
+- Click button "Đăng nhập"
+
+#### **Bước 2: Gửi Request Đến Server**
+```javascript
+POST /api/users/login
+Content-Type: application/json
+
+{
+ "username": "admin", // hoặc email
+ "password": "admin123"
+}
+```
+
+#### **Bước 3: Server Xác Thực**
+
+**3.1. Tìm User trong Database**
+```javascript
+// Tìm user theo username HOẶC email
+UsersAuth.findOne({
+ where: {
+ OR: [
+ { username: username },
+ { email: username }
+ ]
+ }
+})
+```
+
+**3.2. Kiểm Tra Các Điều Kiện**
+- ❌ User không tồn tại → Trả về lỗi 401
+- ❌ Tài khoản bị khóa (`is_locked = true`) → Trả về lỗi 403
+- ❌ Tài khoản không active (`is_active = false`) → Trả về lỗi 403
+
+**3.3. Verify Password**
+```javascript
+// Password được hash theo công thức:
+// hash = bcrypt(password + salt)
+
+const passwordMatch = await bcrypt.compare(
+ password + user.salt,
+ user.password_hash
+);
+```
+
+**3.4. Xử Lý Kết Quả**
+
+**❌ Nếu Password Sai:**
+- Tăng `failed_login_attempts` lên 1
+- Nếu thất bại ≥ 5 lần:
+ - Khóa tài khoản (`is_locked = true`)
+ - Set `locked_until` = hiện tại + 30 phút
+- Trả về lỗi 401
+
+**✅ Nếu Password Đúng:**
+- Reset `failed_login_attempts = 0`
+- Tăng `login_count` lên 1
+- Cập nhật `last_login` = thời gian hiện tại
+- Cập nhật `last_login_ip` = IP của client
+- Tạo `session_id` mới (UUID)
+
+#### **Bước 4: Tạo JWT Token**
+```javascript
+const token = jwt.sign(
+ {
+ userId: user.id,
+ username: user.username,
+ email: user.email,
+ sessionId: sessionId
+ },
+ JWT_SECRET,
+ { expiresIn: '24h' }
+);
+```
+
+#### **Bước 5: Trả Response Về Client**
+```json
+{
+ "success": true,
+ "message": "Đăng nhập thành công",
+ "data": {
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "user": {
+ "id": "uuid-here",
+ "username": "admin",
+ "email": "admin@senaai.tech",
+ "profile": { ... },
+ "last_login": "2026-01-19T...",
+ "login_count": 42
+ }
+ }
+}
+```
+
+#### **Bước 6: Client Lưu Token**
+```javascript
+// Lưu token vào localStorage
+localStorage.setItem('token', data.data.token);
+```
+
+### 3️⃣ Sử Dụng Token Để Xác Thực
+
+**Các Request Tiếp Theo:**
+```javascript
+fetch('/api/users/profile', {
+ headers: {
+ 'Authorization': 'Bearer ' + token
+ }
+})
+```
+
+**Server Verify Token:**
+```javascript
+POST /api/users/verify-token
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+```
+
+### 4️⃣ Đăng Xuất
+
+```javascript
+POST /api/users/logout
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+
+// Server sẽ:
+// 1. Xóa current_session_id trong database
+// 2. Token sẽ không còn valid nữa
+```
+
+## 🔒 Bảo Mật
+
+### Password Security
+- **Salt**: Mỗi user có salt riêng (16 bytes random)
+- **Hash Algorithm**: bcrypt với cost factor = 10
+- **Formula**: `hash = bcrypt(password + salt, 10)`
+
+### Token Security
+- **Algorithm**: JWT với HS256
+- **Expiration**: 24 giờ
+- **Secret Key**: Lưu trong environment variable
+- **Session Tracking**: Mỗi lần login tạo session_id mới
+
+### Brute Force Protection
+- Khóa tài khoản sau 5 lần đăng nhập sai
+- Thời gian khóa: 30 phút
+- Tự động mở khóa sau khi hết thời gian
+
+## 📊 Database Schema
+
+### Table: `users_auth`
+```sql
+- id (UUID, PK)
+- username (VARCHAR, UNIQUE)
+- email (VARCHAR, UNIQUE)
+- password_hash (VARCHAR)
+- salt (VARCHAR)
+- current_session_id (VARCHAR)
+- last_login (DATETIME)
+- last_login_ip (VARCHAR)
+- login_count (INT)
+- failed_login_attempts (INT)
+- is_active (BOOLEAN)
+- is_locked (BOOLEAN)
+- locked_until (DATETIME)
+```
+
+## 🚀 API Endpoints
+
+### Authentication
+| Method | Endpoint | Mô Tả |
+|--------|----------|-------|
+| POST | `/api/users/login` | Đăng nhập |
+| POST | `/api/users/register` | Đăng ký tài khoản mới |
+| POST | `/api/users/verify-token` | Xác thực token |
+| POST | `/api/users/logout` | Đăng xuất |
+
+### User Management
+| Method | Endpoint | Mô Tả |
+|--------|----------|-------|
+| GET | `/api/users` | Lấy danh sách users |
+| GET | `/api/users/:id` | Lấy thông tin user theo ID |
+| POST | `/api/users` | Tạo user mới |
+| PUT | `/api/users/:id` | Cập nhật user |
+| DELETE | `/api/users/:id` | Xóa user (soft delete) |
+
+## 🧪 Testing
+
+### 1. Tạo Test Users
+```bash
+node scripts/create-test-user.js
+```
+
+### 2. Mở Login Page
+```
+http://localhost:4000/login.html
+```
+
+### 3. Test Login
+**Test Accounts:**
+- **Admin**: `admin` / `admin123`
+- **Teacher**: `teacher1` / `teacher123`
+- **Student**: `student1` / `student123`
+
+### 4. Test Với cURL
+
+**Login:**
+```bash
+curl -X POST http://localhost:4000/api/users/login \
+ -H "Content-Type: application/json" \
+ -d '{"username":"admin","password":"admin123"}'
+```
+
+**Verify Token:**
+```bash
+curl -X POST http://localhost:4000/api/users/verify-token \
+ -H "Authorization: Bearer YOUR_TOKEN_HERE"
+```
+
+**Logout:**
+```bash
+curl -X POST http://localhost:4000/api/users/logout \
+ -H "Authorization: Bearer YOUR_TOKEN_HERE"
+```
+
+## 🎯 Luồng Xử Lý (Flow Diagram)
+
+```
+User Input
+ │
+ ▼
+[Frontend] login.html
+ │ POST /api/users/login
+ │ { username, password }
+ ▼
+[Backend] userController.login()
+ │
+ ├──▶ Tìm user trong DB
+ │ ├─ Không tìm thấy → 401 Unauthorized
+ │ └─ Tìm thấy ✓
+ │
+ ├──▶ Kiểm tra is_locked
+ │ ├─ Bị khóa → 403 Forbidden
+ │ └─ Không khóa ✓
+ │
+ ├──▶ Kiểm tra is_active
+ │ ├─ Không active → 403 Forbidden
+ │ └─ Active ✓
+ │
+ ├──▶ Verify password với bcrypt
+ │ ├─ Sai → Tăng failed_attempts
+ │ │ ├─ ≥5 lần → Khóa tài khoản
+ │ │ └─ 401 Unauthorized
+ │ └─ Đúng ✓
+ │
+ ├──▶ Cập nhật thông tin login
+ │ ├─ Reset failed_attempts = 0
+ │ ├─ Tăng login_count
+ │ ├─ Cập nhật last_login
+ │ ├─ Cập nhật last_login_ip
+ │ └─ Tạo session_id mới
+ │
+ ├──▶ Tạo JWT token
+ │ └─ Expire sau 24h
+ │
+ └──▶ Return response
+ └─ { token, user }
+ │
+ ▼
+[Frontend] Nhận response
+ │
+ ├──▶ Lưu token vào localStorage
+ ├──▶ Hiển thị thông báo thành công
+ └──▶ Sử dụng token cho các request sau
+```
+
+## 💡 Best Practices
+
+### Frontend
+- ✅ Lưu token trong localStorage
+- ✅ Gửi token trong header: `Authorization: Bearer TOKEN`
+- ✅ Xử lý token expired (redirect về login)
+- ✅ Clear token khi logout
+
+### Backend
+- ✅ Validate input (username, password)
+- ✅ Rate limiting để chống brute force
+- ✅ Log các lần đăng nhập thất bại
+- ✅ Use HTTPS trong production
+- ✅ Rotate JWT secret định kỳ
+
+### Database
+- ✅ Index trên username, email
+- ✅ Không bao giờ lưu plain password
+- ✅ Use UUID cho ID
+- ✅ Soft delete (is_active) thay vì DELETE
+
+## 🔍 Troubleshooting
+
+### "401 Unauthorized"
+- Kiểm tra username/password có đúng không
+- Kiểm tra user có tồn tại trong DB không
+
+### "403 Forbidden - Account Locked"
+- Tài khoản bị khóa do nhập sai password ≥5 lần
+- Đợi 30 phút hoặc admin mở khóa
+
+### "Token Invalid"
+- Token đã expire (>24h)
+- Session đã bị logout
+- Đăng nhập lại để lấy token mới
+
+## 📚 Dependencies
+
+- `bcrypt`: Hash passwords
+- `jsonwebtoken`: Tạo và verify JWT tokens
+- `crypto`: Generate salt và secrets
+- `sequelize`: ORM for MySQL
+- `express`: Web framework
+
+---
+
+**Created by Sena Team** 🚀
diff --git a/README.md b/README.md
deleted file mode 100644
index 85d95ba..0000000
--- a/README.md
+++ /dev/null
@@ -1,2 +0,0 @@
-# sena_db_api_layer
-
diff --git a/app.js b/app.js
new file mode 100644
index 0000000..b23a9bb
--- /dev/null
+++ b/app.js
@@ -0,0 +1,244 @@
+const express = require('express');
+const helmet = require('helmet');
+const cors = require('cors');
+const compression = require('compression');
+const morgan = require('morgan');
+require('express-async-errors'); // Handle async errors
+const config = require('./config/config.json');
+
+// Import configurations
+const { initializeDatabase } = require('./config/database');
+const { setupRelationships } = require('./models');
+const { redisClient } = require('./config/redis');
+
+// Import middleware
+const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
+
+// Import routes
+const authRoutes = require('./routes/authRoutes');
+const schoolRoutes = require('./routes/schoolRoutes');
+const classRoutes = require('./routes/classRoutes');
+const academicYearRoutes = require('./routes/academicYearRoutes');
+const subjectRoutes = require('./routes/subjectRoutes');
+const userRoutes = require('./routes/userRoutes');
+const studentRoutes = require('./routes/studentRoutes');
+const teacherRoutes = require('./routes/teacherRoutes');
+const roomRoutes = require('./routes/roomRoutes');
+const attendanceRoutes = require('./routes/attendanceRoutes');
+const gradeRoutes = require('./routes/gradeRoutes');
+const subscriptionRoutes = require('./routes/subscriptionRoutes');
+const trainingRoutes = require('./routes/trainingRoutes');
+const parentTaskRoutes = require('./routes/parentTaskRoutes');
+const chapterRoutes = require('./routes/chapterRoutes');
+const gameRoutes = require('./routes/gameRoutes');
+
+/**
+ * Initialize Express Application
+ */
+const app = express();
+
+/**
+ * Security Middleware
+ */
+app.use(helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ scriptSrc: ["'self'"],
+ styleSrc: ["'self'", "'unsafe-inline'"],
+ imgSrc: ["'self'", "data:", "https:"],
+ connectSrc: ["'self'"],
+ fontSrc: ["'self'"],
+ objectSrc: ["'none'"],
+ mediaSrc: ["'self'"],
+ frameSrc: ["'none'"],
+ },
+ },
+}));
+app.use(cors({
+ origin: config.cors.origin,
+ credentials: true,
+}));
+
+/**
+ * Body Parsing Middleware
+ */
+app.use(express.json({ limit: '10mb' }));
+app.use(express.urlencoded({ extended: true, limit: '10mb' }));
+
+/**
+ * Static Files Middleware
+ */
+app.use(express.static('public'));
+
+/**
+ * Compression Middleware
+ */
+app.use(compression());
+
+/**
+ * Logging Middleware
+ */
+if (config.server.env === 'development') {
+ app.use(morgan('dev'));
+} else {
+ app.use(morgan('combined'));
+}
+
+/**
+ * Health Check Endpoint
+ */
+app.get('/health', async (req, res) => {
+ try {
+ // Check database connection
+ await require('./config/database').testConnection();
+
+ // Check Redis connection
+ await redisClient.ping();
+
+ res.json({
+ success: true,
+ status: 'healthy',
+ timestamp: new Date().toISOString(),
+ uptime: process.uptime(),
+ environment: config.server.env,
+ services: {
+ database: 'connected',
+ redis: 'connected',
+ },
+ });
+ } catch (error) {
+ res.status(503).json({
+ success: false,
+ status: 'unhealthy',
+ error: error.message,
+ });
+ }
+});
+
+/**
+ * API Info Endpoint
+ */
+app.get('/api', (req, res) => {
+ res.json({
+ success: true,
+ name: 'Sena School Management API',
+ version: '1.0.0',
+ description: 'API for managing 200 schools with Redis caching and BullMQ job processing',
+ enauth: '/api/auth',
+ dpoints: {
+ schools: '/api/schools',
+ classes: '/api/classes',
+ academicYears: '/api/academic-years',
+ subjects: '/api/subjects',
+ users: '/api/users',
+ students: '/api/students',
+ teachers: '/api/teachers',
+ rooms: '/api/rooms',
+ attendance: '/api/attendance',
+ grades: '/api/grades',
+ subscriptions: '/api/subscriptions',
+ training: '/api/training',
+ parentTasks: '/api/parent-tasks',
+ chapters: '/api/chapters',
+ games: '/api/games',
+ },
+ documentation: '/api/docs',
+ });
+});
+
+/**
+ * API Routes
+ */
+app.use('/api/auth', authRoutes);
+app.use('/api/schools', schoolRoutes);
+app.use('/api/classes', classRoutes);
+app.use('/api/academic-years', academicYearRoutes);
+app.use('/api/subjects', subjectRoutes);
+app.use('/api/users', userRoutes);
+app.use('/api/students', studentRoutes);
+app.use('/api/teachers', teacherRoutes);
+app.use('/api/rooms', roomRoutes);
+app.use('/api/attendance', attendanceRoutes);
+app.use('/api/grades', gradeRoutes);
+app.use('/api/subscriptions', subscriptionRoutes);
+app.use('/api/training', trainingRoutes);
+app.use('/api/parent-tasks', parentTaskRoutes);
+app.use('/api/chapters', chapterRoutes);
+app.use('/api/games', gameRoutes);
+
+/**
+ * Queue Status Endpoint
+ */
+app.get('/api/queues/status', async (req, res) => {
+ try {
+ const { getQueueMetrics, QueueNames } = require('./config/bullmq');
+
+ const metrics = await Promise.all(
+ Object.values(QueueNames).map(queueName => getQueueMetrics(queueName))
+ );
+
+ res.json({
+ success: true,
+ data: metrics,
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ error: error.message,
+ });
+ }
+});
+
+/**
+ * Cache Stats Endpoint
+ */
+app.get('/api/cache/stats', async (req, res) => {
+ try {
+ const info = await redisClient.info('stats');
+ const dbSize = await redisClient.dbsize();
+
+ res.json({
+ success: true,
+ data: {
+ dbSize,
+ stats: info,
+ },
+ });
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ error: error.message,
+ });
+ }
+});
+
+/**
+ * 404 Handler
+ */
+app.use(notFoundHandler);
+
+/**
+ * Global Error Handler
+ */
+app.use(errorHandler);
+
+/**
+ * Initialize Application
+ */
+const initializeApp = async () => {
+ try {
+ // Initialize database connection
+ await initializeDatabase();
+
+ // Setup model relationships
+ setupRelationships();
+
+ console.log('✅ Application initialized successfully');
+ } catch (error) {
+ console.error('❌ Application initialization failed:', error.message);
+ throw error;
+ }
+};
+
+module.exports = { app, initializeApp };
diff --git a/config/bullmq.js b/config/bullmq.js
new file mode 100644
index 0000000..c00ef9c
--- /dev/null
+++ b/config/bullmq.js
@@ -0,0 +1,256 @@
+const { Queue, Worker } = require('bullmq');
+const Redis = require('ioredis');
+const config = require('./config.json');
+
+/**
+ * BullMQ Connection Configuration - Direct Redis connection
+ */
+const redisConfig = config.redis;
+
+// Connection options for BullMQ (BullMQ will create connections using these options)
+const connectionOptions = {
+ host: redisConfig.cluster[0].host,
+ port: redisConfig.cluster[0].port,
+ password: redisConfig.password,
+ db: redisConfig.db,
+ maxRetriesPerRequest: null,
+ enableReadyCheck: false,
+};
+
+// Create a shared Redis connection for direct Redis operations
+const bullMQConnection = new Redis(connectionOptions);
+
+console.log('📊 BullMQ Redis Connection:', {
+ host: connectionOptions.host,
+ port: connectionOptions.port,
+ db: connectionOptions.db,
+});
+
+bullMQConnection.on('connect', () => {
+ console.log('✅ BullMQ Redis connected');
+});
+
+bullMQConnection.on('error', (err) => {
+ console.error('❌ BullMQ Redis error:', err.message);
+});
+
+/**
+ * Default job options
+ */
+const defaultJobOptions = {
+ attempts: 3,
+ backoff: {
+ type: 'exponential',
+ delay: 2000,
+ },
+ removeOnComplete: {
+ age: 24 * 3600, // Keep completed jobs for 24 hours
+ count: 1000,
+ },
+ removeOnFail: {
+ age: 7 * 24 * 3600, // Keep failed jobs for 7 days
+ },
+};
+
+/**
+ * Queue definitions for different operations
+ */
+const QueueNames = {
+ DATABASE_WRITE: 'database-write',
+ NOTIFICATION: 'notification',
+ ATTENDANCE_PROCESS: 'attendance-process',
+ GRADE_CALCULATION: 'grade-calculation',
+ REPORT_GENERATION: 'report-generation',
+};
+
+/**
+ * Create queues with connection options
+ */
+const queues = {};
+
+Object.values(QueueNames).forEach(queueName => {
+ queues[queueName] = new Queue(queueName, {
+ connection: connectionOptions, // Use connection options, not instance
+ prefix: process.env.BULLMQ_PREFIX || 'vcb',
+ defaultJobOptions,
+ });
+
+ queues[queueName].on('error', (error) => {
+ console.error(`❌ Queue ${queueName} error:`, error.message);
+ });
+
+ console.log(`✅ Queue ${queueName} initialized`);
+});
+
+/**
+ * Add job to database write queue
+ * @param {string} operation - Operation type: 'create', 'update', 'delete'
+ * @param {string} model - Model name
+ * @param {object} data - Data to be written
+ * @param {object} options - Additional options
+ */
+const addDatabaseWriteJob = async (operation, model, data, options = {}) => {
+ try {
+ const job = await queues[QueueNames.DATABASE_WRITE].add(
+ `${operation}-${model}`,
+ {
+ operation,
+ model,
+ data,
+ timestamp: new Date().toISOString(),
+ userId: options.userId,
+ ...options,
+ },
+ {
+ priority: options.priority || 5,
+ delay: options.delay || 0,
+ }
+ );
+
+ console.log(`✅ Database write job added: ${job.id} (${operation} ${model})`);
+ return job;
+ } catch (error) {
+ console.error(`❌ Error adding database write job:`, error.message);
+ throw error;
+ }
+};
+
+/**
+ * Add notification job
+ */
+const addNotificationJob = async (type, recipients, content, options = {}) => {
+ try {
+ const job = await queues[QueueNames.NOTIFICATION].add(
+ `send-${type}`,
+ {
+ type,
+ recipients,
+ content,
+ timestamp: new Date().toISOString(),
+ ...options,
+ },
+ {
+ priority: options.priority || 5,
+ }
+ );
+
+ console.log(`✅ Notification job added: ${job.id}`);
+ return job;
+ } catch (error) {
+ console.error(`❌ Error adding notification job:`, error.message);
+ throw error;
+ }
+};
+
+/**
+ * Add attendance processing job
+ */
+const addAttendanceProcessJob = async (schoolId, date, options = {}) => {
+ try {
+ const job = await queues[QueueNames.ATTENDANCE_PROCESS].add(
+ 'process-attendance',
+ {
+ schoolId,
+ date,
+ timestamp: new Date().toISOString(),
+ ...options,
+ },
+ {
+ priority: options.priority || 3,
+ }
+ );
+
+ console.log(`✅ Attendance process job added: ${job.id}`);
+ return job;
+ } catch (error) {
+ console.error(`❌ Error adding attendance process job:`, error.message);
+ throw error;
+ }
+};
+
+/**
+ * Add grade calculation job
+ */
+const addGradeCalculationJob = async (studentId, academicYearId, options = {}) => {
+ try {
+ const job = await queues[QueueNames.GRADE_CALCULATION].add(
+ 'calculate-grades',
+ {
+ studentId,
+ academicYearId,
+ timestamp: new Date().toISOString(),
+ ...options,
+ },
+ {
+ priority: options.priority || 4,
+ }
+ );
+
+ console.log(`✅ Grade calculation job added: ${job.id}`);
+ return job;
+ } catch (error) {
+ console.error(`❌ Error adding grade calculation job:`, error.message);
+ throw error;
+ }
+};
+
+/**
+ * Get queue metrics
+ */
+const getQueueMetrics = async (queueName) => {
+ try {
+ const queue = queues[queueName];
+ if (!queue) {
+ throw new Error(`Queue ${queueName} not found`);
+ }
+
+ const [waiting, active, completed, failed, delayed] = await Promise.all([
+ queue.getWaitingCount(),
+ queue.getActiveCount(),
+ queue.getCompletedCount(),
+ queue.getFailedCount(),
+ queue.getDelayedCount(),
+ ]);
+
+ return {
+ queueName,
+ waiting,
+ active,
+ completed,
+ failed,
+ delayed,
+ total: waiting + active + completed + failed + delayed,
+ };
+ } catch (error) {
+ console.error(`❌ Error getting queue metrics:`, error.message);
+ throw error;
+ }
+};
+
+/**
+ * Close all queues
+ */
+const closeQueues = async () => {
+ try {
+ await Promise.all(
+ Object.values(queues).map(queue => queue.close())
+ );
+ console.log('✅ All BullMQ queues closed');
+ } catch (error) {
+ console.error('❌ Error closing queues:', error.message);
+ }
+};
+
+module.exports = {
+ queues,
+ QueueNames,
+ addDatabaseWriteJob,
+ addNotificationJob,
+ addAttendanceProcessJob,
+ addGradeCalculationJob,
+ getQueueMetrics,
+ closeQueues,
+ bullMQConnection,
+ connectionOptions,
+ defaultJobOptions,
+};
diff --git a/config/config.json b/config/config.json
new file mode 100644
index 0000000..727c10b
--- /dev/null
+++ b/config/config.json
@@ -0,0 +1,40 @@
+{
+ "database": {
+ "host": "senaai.tech",
+ "port": 11001,
+ "username": "root",
+ "password": "Sena@2026!",
+ "database": "sena_school_db",
+ "dialect": "mysql",
+ "pool": {
+ "max": 20,
+ "min": 5,
+ "acquire": 60000,
+ "idle": 10000
+ },
+ "logging": false,
+ "timezone": "+07:00"
+ },
+ "redis": {
+ "cluster": [
+ {
+ "host": "senaai.tech",
+ "port": 11010
+ },
+ {
+ "host": "senaai.tech",
+ "port": 11011
+ }
+ ],
+ "password": "Sena@2026!",
+ "db": 0,
+ "keyPrefix": "sena:"
+ },
+ "server": {
+ "port" : 4000,
+ "env": "production"
+ },
+ "cors": {
+ "origin": "*"
+ }
+}
diff --git a/config/database.js b/config/database.js
new file mode 100644
index 0000000..7d81471
--- /dev/null
+++ b/config/database.js
@@ -0,0 +1,98 @@
+const { Sequelize } = require('sequelize');
+const config = require('./config.json');
+
+/**
+ * MySQL Connection Configuration via senaai.tech
+ * Direct connection to MySQL server (port 11001)
+ */
+const dbConfig = config.database;
+
+const sequelize = new Sequelize(
+ dbConfig.database,
+ dbConfig.username,
+ dbConfig.password,
+ {
+ host: dbConfig.host,
+ port: dbConfig.port,
+ dialect: dbConfig.dialect,
+ dialectOptions: {
+ connectTimeout: 60000, // 60 seconds for slow networks
+ },
+
+ // Connection Pool Configuration
+ pool: dbConfig.pool,
+
+ // Logging
+ logging: dbConfig.logging,
+
+ // Query options
+ define: {
+ timestamps: true,
+ underscored: true,
+ freezeTableName: true,
+ },
+
+ // Timezone
+ timezone: dbConfig.timezone,
+
+ // Retry configuration
+ retry: {
+ max: 3,
+ timeout: 3000,
+ },
+ }
+);
+
+/**
+ * Test database connection
+ */
+const testConnection = async () => {
+ try {
+ await sequelize.authenticate();
+ console.log(`✅ MySQL connection established successfully to ${dbConfig.host}:${dbConfig.port}`);
+ return true;
+ } catch (error) {
+ console.error('❌ Unable to connect to MySQL:', error.message);
+ return false;
+ }
+};
+
+/**
+ * Initialize database models
+ */
+const initializeDatabase = async () => {
+ try {
+ // Test connection first
+ const isConnected = await testConnection();
+ if (!isConnected) {
+ throw new Error('Failed to connect to database');
+ }
+
+ // Note: Auto-sync disabled in production for safety
+ console.log('✅ Database connection ready');
+
+ return sequelize;
+ } catch (error) {
+ console.error('❌ Database initialization failed:', error.message);
+ throw error;
+ }
+};
+
+/**
+ * Close database connection
+ */
+const closeConnection = async () => {
+ try {
+ await sequelize.close();
+ console.log('✅ Database connection closed');
+ } catch (error) {
+ console.error('❌ Error closing database connection:', error.message);
+ }
+};
+
+module.exports = {
+ sequelize,
+ testConnection,
+ initializeDatabase,
+ closeConnection,
+};
diff --git a/config/redis.js b/config/redis.js
new file mode 100644
index 0000000..fc39811
--- /dev/null
+++ b/config/redis.js
@@ -0,0 +1,169 @@
+const Redis = require('ioredis');
+const config = require('./config.json');
+
+/**
+ * Redis Connection - Direct connection to master
+ * For Sentinel HA from external clients, we connect directly to the master port
+ * and rely on manual failover by trying both ports
+ */
+const redisConfig = config.redis;
+
+// Direct connection to master (port 11010)
+const redisClient = new Redis({
+ host: redisConfig.cluster[0].host,
+ port: redisConfig.cluster[0].port,
+ password: redisConfig.password,
+ db: redisConfig.db,
+ keyPrefix: redisConfig.keyPrefix,
+ connectTimeout: 10000,
+
+ retryStrategy: (times) => {
+ if (times > 10) {
+ console.log(`⚠️ Redis retry exhausted after ${times} attempts`);
+ return null;
+ }
+ const delay = Math.min(times * 100, 3000);
+ console.log(`🔄 Redis retry attempt ${times}, delay ${delay}ms`);
+ return delay;
+ },
+
+ // Reconnect on READONLY error (slave promoted)
+ reconnectOnError: (err) => {
+ if (err.message.includes('READONLY')) {
+ console.log('⚠️ READONLY error detected - slave may have been promoted');
+ return true;
+ }
+ return false;
+ },
+
+ enableOfflineQueue: true,
+ maxRetriesPerRequest: null,
+ enableReadyCheck: true,
+});
+
+/**
+ * Redis Event Handlers
+ */
+redisClient.on('connect', () => {
+ console.log('✅ Redis client connected');
+});
+
+redisClient.on('ready', () => {
+ console.log('✅ Redis client ready');
+});
+
+redisClient.on('error', (err) => {
+ console.error('❌ Redis error:', err.message);
+});
+
+redisClient.on('close', () => {
+ console.log('⚠️ Redis client closed');
+});
+
+redisClient.on('reconnecting', () => {
+ console.log('🔄 Redis client reconnecting...');
+});
+
+/**
+ * Cache utility functions
+ */
+const cacheUtils = {
+ /**
+ * Get value from cache
+ */
+ get: async (key) => {
+ try {
+ const value = await redisClient.get(key);
+ return value ? JSON.parse(value) : null;
+ } catch (error) {
+ console.error(`Error getting cache for key ${key}:`, error.message);
+ return null;
+ }
+ },
+
+ /**
+ * Set value to cache with TTL
+ */
+ set: async (key, value, ttl = 3600) => {
+ try {
+ const serialized = JSON.stringify(value);
+ await redisClient.setex(key, ttl, serialized);
+ return true;
+ } catch (error) {
+ console.error(`Error setting cache for key ${key}:`, error.message);
+ return false;
+ }
+ },
+
+ /**
+ * Delete cache by key
+ */
+ delete: async (key) => {
+ try {
+ await redisClient.del(key);
+ return true;
+ } catch (error) {
+ console.error(`Error deleting cache for key ${key}:`, error.message);
+ return false;
+ }
+ },
+
+ /**
+ * Delete cache by pattern
+ */
+ deletePattern: async (pattern) => {
+ try {
+ const keys = await redisClient.keys(pattern);
+ if (keys.length > 0) {
+ await redisClient.del(...keys);
+ }
+ return keys.length;
+ } catch (error) {
+ console.error(`Error deleting cache pattern ${pattern}:`, error.message);
+ return 0;
+ }
+ },
+
+ /**
+ * Check if key exists
+ */
+ exists: async (key) => {
+ try {
+ const result = await redisClient.exists(key);
+ return result === 1;
+ } catch (error) {
+ console.error(`Error checking cache existence for key ${key}:`, error.message);
+ return false;
+ }
+ },
+
+ /**
+ * Get TTL for key
+ */
+ ttl: async (key) => {
+ try {
+ return await redisClient.ttl(key);
+ } catch (error) {
+ console.error(`Error getting TTL for key ${key}:`, error.message);
+ return -1;
+ }
+ },
+};
+
+/**
+ * Close Redis connection
+ */
+const closeRedisConnection = async () => {
+ try {
+ await redisClient.quit();
+ console.log('✅ Redis connection closed gracefully');
+ } catch (error) {
+ console.error('❌ Error closing Redis connection:', error.message);
+ }
+};
+
+module.exports = {
+ redisClient,
+ cacheUtils,
+ closeRedisConnection,
+};
diff --git a/controllers/academicYearController.js b/controllers/academicYearController.js
new file mode 100644
index 0000000..8ae7bbe
--- /dev/null
+++ b/controllers/academicYearController.js
@@ -0,0 +1,270 @@
+const { AcademicYear } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob } = require('../config/bullmq');
+
+/**
+ * Academic Year Controller - Quản lý năm học
+ */
+class AcademicYearController {
+ /**
+ * Get all academic years with pagination and caching
+ */
+ async getAllAcademicYears(req, res, next) {
+ try {
+ const { page = 1, limit = 20, is_current } = req.query;
+ const offset = (page - 1) * limit;
+
+ // Generate cache key
+ const cacheKey = `academic_years:list:${page}:${limit}:${is_current || 'all'}`;
+
+ // Try to get from cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Build query conditions
+ const where = {};
+ if (is_current !== undefined) where.is_current = is_current === 'true';
+
+ // Query from database (through ProxySQL)
+ const { count, rows } = await AcademicYear.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ order: [['start_date', 'DESC']],
+ });
+
+ const result = {
+ academicYears: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ // Cache the result
+ await cacheUtils.set(cacheKey, result, 3600); // Cache for 1 hour
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get academic year by ID with caching
+ */
+ async getAcademicYearById(req, res, next) {
+ try {
+ const { id } = req.params;
+ const cacheKey = `academic_year:${id}`;
+
+ // Try cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Query from database
+ const academicYear = await AcademicYear.findByPk(id);
+
+ if (!academicYear) {
+ return res.status(404).json({
+ success: false,
+ message: 'Academic year not found',
+ });
+ }
+
+ // Cache the result
+ await cacheUtils.set(cacheKey, academicYear, 7200); // Cache for 2 hours
+
+ res.json({
+ success: true,
+ data: academicYear,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get current academic year with caching
+ */
+ async getCurrentAcademicYear(req, res, next) {
+ try {
+ const cacheKey = 'academic_year:current';
+
+ // Try cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Query from database
+ const academicYear = await AcademicYear.findOne({
+ where: { is_current: true },
+ });
+
+ if (!academicYear) {
+ return res.status(404).json({
+ success: false,
+ message: 'No current academic year found',
+ });
+ }
+
+ // Cache the result
+ await cacheUtils.set(cacheKey, academicYear, 3600); // Cache for 1 hour
+
+ res.json({
+ success: true,
+ data: academicYear,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Create new academic year (async via BullMQ)
+ */
+ async createAcademicYear(req, res, next) {
+ try {
+ const academicYearData = req.body;
+
+ // Add to job queue for async processing
+ const job = await addDatabaseWriteJob('create', 'AcademicYear', academicYearData);
+
+ // Invalidate related caches
+ await cacheUtils.deletePattern('academic_years:list:*');
+ await cacheUtils.delete('academic_year:current');
+
+ res.status(202).json({
+ success: true,
+ message: 'Academic year creation job queued',
+ jobId: job.id,
+ data: academicYearData,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Update academic year (async via BullMQ)
+ */
+ async updateAcademicYear(req, res, next) {
+ try {
+ const { id } = req.params;
+ const updates = req.body;
+
+ // Add to job queue for async processing
+ const job = await addDatabaseWriteJob('update', 'AcademicYear', {
+ id,
+ updates,
+ });
+
+ // Invalidate related caches
+ await cacheUtils.delete(`academic_year:${id}`);
+ await cacheUtils.deletePattern('academic_years:list:*');
+ await cacheUtils.delete('academic_year:current');
+
+ res.status(202).json({
+ success: true,
+ message: 'Academic year update job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Delete academic year (async via BullMQ)
+ */
+ async deleteAcademicYear(req, res, next) {
+ try {
+ const { id } = req.params;
+
+ // Add to job queue for async processing
+ const job = await addDatabaseWriteJob('delete', 'AcademicYear', { id });
+
+ // Invalidate related caches
+ await cacheUtils.delete(`academic_year:${id}`);
+ await cacheUtils.deletePattern('academic_years:list:*');
+ await cacheUtils.delete('academic_year:current');
+
+ res.status(202).json({
+ success: true,
+ message: 'Academic year deletion job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Set academic year as current
+ */
+ async setCurrentAcademicYear(req, res, next) {
+ try {
+ const { id } = req.params;
+
+ // Add to job queue to update current year
+ const job = await addDatabaseWriteJob('update', 'AcademicYear', {
+ id,
+ updates: { is_current: true },
+ // Will also set all other years to is_current: false
+ });
+
+ // Invalidate all academic year caches
+ await cacheUtils.deletePattern('academic_year*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Set current academic year job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get academic year datatypes
+ */
+ async getAcademicYearDatatypes(req, res, next) {
+ try {
+ const datatypes = AcademicYear.rawAttributes;
+ res.json({
+ success: true,
+ data: datatypes,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new AcademicYearController();
diff --git a/controllers/attendanceController.js b/controllers/attendanceController.js
new file mode 100644
index 0000000..60d795e
--- /dev/null
+++ b/controllers/attendanceController.js
@@ -0,0 +1,248 @@
+const { AttendanceLog, AttendanceDaily, UsersAuth, School } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob, addAttendanceProcessJob } = require('../config/bullmq');
+
+/**
+ * Attendance Controller - Quản lý điểm danh
+ */
+class AttendanceController {
+ /**
+ * Get attendance logs with pagination
+ */
+ async getAttendanceLogs(req, res, next) {
+ try {
+ const { page = 1, limit = 50, user_id, school_id, date_from, date_to } = req.query;
+ const offset = (page - 1) * limit;
+
+ const cacheKey = `attendance:logs:${page}:${limit}:${user_id || 'all'}:${school_id || 'all'}:${date_from || ''}:${date_to || ''}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = {};
+ if (user_id) where.user_id = user_id;
+ if (school_id) where.school_id = school_id;
+ if (date_from && date_to) {
+ where.check_time = {
+ [require('sequelize').Op.between]: [date_from, date_to],
+ };
+ }
+
+ const { count, rows } = await AttendanceLog.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ include: [
+ {
+ model: UsersAuth,
+ as: 'user',
+ attributes: ['username', 'email'],
+ },
+ {
+ model: School,
+ as: 'school',
+ attributes: ['school_code', 'school_name'],
+ },
+ ],
+ order: [['check_time', 'DESC']],
+ });
+
+ const result = {
+ logs: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ await cacheUtils.set(cacheKey, result, 300); // Cache 5 minutes
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Create attendance log (QR scan)
+ */
+ async createAttendanceLog(req, res, next) {
+ try {
+ const logData = req.body;
+
+ const job = await addDatabaseWriteJob('create', 'AttendanceLog', logData);
+
+ await cacheUtils.deletePattern('attendance:logs:*');
+ await cacheUtils.deletePattern('attendance:daily:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Attendance log created',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get daily attendance summary
+ */
+ async getDailyAttendance(req, res, next) {
+ try {
+ const { page = 1, limit = 50, school_id, class_id } = req.query;
+ let { date } = req.query;
+ const offset = (page - 1) * limit;
+
+ // Use today's date if not provided
+ if (!date) {
+ date = new Date().toISOString().split('T')[0]; // Format: YYYY-MM-DD
+ }
+
+ const cacheKey = `attendance:daily:${school_id || 'all'}:${date}:${class_id || 'all'}:${page}:${limit}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = { attendance_date: date };
+ if (school_id) where.school_id = school_id;
+ if (class_id) where.class_id = class_id;
+
+ const { count, rows } = await AttendanceDaily.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ include: [{
+ model: UsersAuth,
+ as: 'user',
+ attributes: ['username'],
+ }],
+ order: [['status', 'ASC']],
+ });
+
+ const result = {
+ attendance: rows,
+ date: date,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ await cacheUtils.set(cacheKey, result, 1800);
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Process daily attendance (aggregate from logs)
+ */
+ async processAttendance(req, res, next) {
+ try {
+ const { school_id, date } = req.body;
+
+ const job = await addAttendanceProcessJob(school_id, date);
+
+ await cacheUtils.deletePattern('attendance:daily:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Attendance processing job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get attendance statistics
+ */
+ async getAttendanceStats(req, res, next) {
+ try {
+ const { school_id, date_from, date_to } = req.query;
+ const cacheKey = `attendance:stats:${school_id}:${date_from}:${date_to}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const stats = await AttendanceDaily.findAll({
+ where: {
+ school_id,
+ attendance_date: {
+ [require('sequelize').Op.between]: [date_from, date_to],
+ },
+ },
+ attributes: [
+ 'status',
+ [require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
+ ],
+ group: ['status'],
+ raw: true,
+ });
+
+ await cacheUtils.set(cacheKey, stats, 3600);
+
+ res.json({
+ success: true,
+ data: stats,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get attendance datatypes
+ */
+ async getAttendanceDatatypes(req, res, next) {
+ try {
+ const datatypes = {
+ AttendanceLog: AttendanceLog.rawAttributes,
+ AttendanceDaily: AttendanceDaily.rawAttributes,
+ };
+ res.json({
+ success: true,
+ data: datatypes,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new AttendanceController();
diff --git a/controllers/authController.js b/controllers/authController.js
new file mode 100644
index 0000000..4875a42
--- /dev/null
+++ b/controllers/authController.js
@@ -0,0 +1,351 @@
+const { UsersAuth, UserProfile } = require('../models');
+const bcrypt = require('bcrypt');
+const jwt = require('jsonwebtoken');
+const crypto = require('crypto');
+
+// JWT Secret - nên lưu trong environment variable
+const JWT_SECRET = process.env.JWT_SECRET || 'sena-secret-key-2026';
+const JWT_EXPIRES_IN = '24h';
+
+/**
+ * Auth Controller - Xác thực và phân quyền
+ */
+class AuthController {
+ /**
+ * Login - Xác thực người dùng
+ */
+ async login(req, res, next) {
+ try {
+ const { username, password } = req.body;
+
+ // Validate input
+ if (!username || !password) {
+ return res.status(400).json({
+ success: false,
+ message: 'Username và password là bắt buộc',
+ });
+ }
+
+ // Tìm user theo username hoặc email
+ const user = await UsersAuth.findOne({
+ where: {
+ [require('sequelize').Op.or]: [
+ { username: username },
+ { email: username },
+ ],
+ },
+ include: [{
+ model: UserProfile,
+ as: 'profile',
+ }],
+ });
+
+ if (!user) {
+ return res.status(401).json({
+ success: false,
+ message: 'Username hoặc password không đúng',
+ });
+ }
+
+ // Kiểm tra tài khoản có bị khóa không
+ if (user.is_locked) {
+ if (user.locked_until && new Date() < new Date(user.locked_until)) {
+ return res.status(403).json({
+ success: false,
+ message: 'Tài khoản bị khóa đến ' + user.locked_until,
+ });
+ } else {
+ // Mở khóa nếu hết thời gian khóa
+ await user.update({
+ is_locked: false,
+ locked_until: null,
+ failed_login_attempts: 0
+ });
+ }
+ }
+
+ // Kiểm tra tài khoản có active không
+ if (!user.is_active) {
+ return res.status(403).json({
+ success: false,
+ message: 'Tài khoản đã bị vô hiệu hóa',
+ });
+ }
+
+ // Verify password
+ const passwordMatch = await bcrypt.compare(password + user.salt, user.password_hash);
+
+ if (!passwordMatch) {
+ // Tăng số lần đăng nhập thất bại
+ const failedAttempts = user.failed_login_attempts + 1;
+ const updates = { failed_login_attempts: failedAttempts };
+
+ // Khóa tài khoản sau 5 lần thất bại
+ if (failedAttempts >= 5) {
+ updates.is_locked = true;
+ updates.locked_until = new Date(Date.now() + 30 * 60 * 1000); // Khóa 30 phút
+ }
+
+ await user.update(updates);
+
+ return res.status(401).json({
+ success: false,
+ message: 'Username hoặc password không đúng',
+ attemptsLeft: Math.max(0, 5 - failedAttempts),
+ });
+ }
+
+ // Đăng nhập thành công - Reset failed attempts
+ const sessionId = crypto.randomUUID();
+ const clientIp = req.ip || req.connection.remoteAddress;
+
+ await user.update({
+ failed_login_attempts: 0,
+ login_count: user.login_count + 1,
+ last_login: new Date(),
+ last_login_ip: clientIp,
+ current_session_id: sessionId,
+ });
+
+ // Tạo JWT token
+ const token = jwt.sign(
+ {
+ userId: user.id,
+ username: user.username,
+ email: user.email,
+ sessionId: sessionId,
+ },
+ JWT_SECRET,
+ { expiresIn: JWT_EXPIRES_IN }
+ );
+
+ // Return success response
+ res.json({
+ success: true,
+ message: 'Đăng nhập thành công',
+ data: {
+ token,
+ user: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ profile: user.profile,
+ last_login: user.last_login,
+ login_count: user.login_count,
+ },
+ },
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Register - Tạo tài khoản mới
+ */
+ async register(req, res, next) {
+ try {
+ const { username, email, password, full_name, phone, school_id } = req.body;
+
+ // Validate input
+ if (!username || !email || !password) {
+ return res.status(400).json({
+ success: false,
+ message: 'Username, email và password là bắt buộc',
+ });
+ }
+
+ // Kiểm tra username đã tồn tại
+ const existingUser = await UsersAuth.findOne({
+ where: {
+ [require('sequelize').Op.or]: [
+ { username },
+ { email },
+ ],
+ },
+ });
+
+ if (existingUser) {
+ return res.status(409).json({
+ success: false,
+ message: 'Username hoặc email đã tồn tại',
+ });
+ }
+
+ // Hash password
+ const salt = crypto.randomBytes(16).toString('hex');
+ const passwordHash = await bcrypt.hash(password + salt, 10);
+
+ // Tạo user mới
+ const newUser = await UsersAuth.create({
+ username,
+ email,
+ password_hash: passwordHash,
+ salt,
+ qr_secret: crypto.randomBytes(32).toString('hex'),
+ });
+
+ // Tạo profile nếu có thông tin
+ if (full_name || phone || school_id) {
+ await UserProfile.create({
+ user_id: newUser.id,
+ full_name: full_name || username,
+ phone,
+ school_id,
+ });
+ }
+
+ res.status(201).json({
+ success: true,
+ message: 'Đăng ký tài khoản thành công',
+ data: {
+ id: newUser.id,
+ username: newUser.username,
+ email: newUser.email,
+ },
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Verify Token - Xác thực JWT token
+ */
+ async verifyToken(req, res, next) {
+ try {
+ const token = req.headers.authorization?.replace('Bearer ', '');
+
+ if (!token) {
+ return res.status(401).json({
+ success: false,
+ message: 'Token không được cung cấp',
+ });
+ }
+
+ const decoded = jwt.verify(token, JWT_SECRET);
+
+ // Kiểm tra user còn tồn tại và session hợp lệ
+ const user = await UsersAuth.findByPk(decoded.userId, {
+ include: [{
+ model: UserProfile,
+ as: 'profile',
+ }],
+ });
+
+ if (!user || !user.is_active) {
+ return res.status(401).json({
+ success: false,
+ message: 'Token không hợp lệ hoặc tài khoản đã bị vô hiệu hóa',
+ });
+ }
+
+ if (user.current_session_id !== decoded.sessionId) {
+ return res.status(401).json({
+ success: false,
+ message: 'Phiên đăng nhập đã hết hạn',
+ });
+ }
+
+ res.json({
+ success: true,
+ message: 'Token hợp lệ',
+ data: {
+ user: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ profile: user.profile,
+ },
+ },
+ });
+ } catch (error) {
+ if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
+ return res.status(401).json({
+ success: false,
+ message: 'Token không hợp lệ hoặc đã hết hạn',
+ });
+ }
+ next(error);
+ }
+ }
+
+ /**
+ * Logout - Đăng xuất
+ */
+ async logout(req, res, next) {
+ try {
+ const token = req.headers.authorization?.replace('Bearer ', '');
+
+ if (!token) {
+ return res.status(401).json({
+ success: false,
+ message: 'Token không được cung cấp',
+ });
+ }
+
+ const decoded = jwt.verify(token, JWT_SECRET);
+
+ // Xóa session hiện tại
+ await UsersAuth.update(
+ { current_session_id: null },
+ { where: { id: decoded.userId } }
+ );
+
+ res.json({
+ success: true,
+ message: 'Đăng xuất thành công',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get current user info from token
+ */
+ async me(req, res, next) {
+ try {
+ const token = req.headers.authorization?.replace('Bearer ', '');
+
+ if (!token) {
+ return res.status(401).json({
+ success: false,
+ message: 'Token không được cung cấp',
+ });
+ }
+
+ const decoded = jwt.verify(token, JWT_SECRET);
+
+ const user = await UsersAuth.findByPk(decoded.userId, {
+ include: [{
+ model: UserProfile,
+ as: 'profile',
+ }],
+ attributes: { exclude: ['password_hash', 'salt', 'qr_secret'] },
+ });
+
+ if (!user) {
+ return res.status(404).json({
+ success: false,
+ message: 'User không tồn tại',
+ });
+ }
+
+ res.json({
+ success: true,
+ data: user,
+ });
+ } catch (error) {
+ if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
+ return res.status(401).json({
+ success: false,
+ message: 'Token không hợp lệ hoặc đã hết hạn',
+ });
+ }
+ next(error);
+ }
+ }
+}
+
+module.exports = new AuthController();
diff --git a/controllers/chapterController.js b/controllers/chapterController.js
new file mode 100644
index 0000000..4e5a5e4
--- /dev/null
+++ b/controllers/chapterController.js
@@ -0,0 +1,335 @@
+const { Chapter, Subject, Lesson } = require('../models');
+const { cacheUtils } = require('../config/redis');
+
+/**
+ * Chapter Controller - Quản lý chương học
+ */
+class ChapterController {
+ /**
+ * Get all chapters with pagination
+ */
+ async getAllChapters(req, res, next) {
+ try {
+ const { page = 1, limit = 20, subject_id, is_published } = req.query;
+ const offset = (page - 1) * limit;
+
+ const cacheKey = `chapters:list:${page}:${limit}:${subject_id || 'all'}:${is_published || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = {};
+ if (subject_id) where.subject_id = subject_id;
+ if (is_published !== undefined) where.is_published = is_published === 'true';
+
+ const { count, rows } = await Chapter.findAndCountAll({
+ where,
+ include: [
+ {
+ model: Subject,
+ as: 'subject',
+ attributes: ['id', 'subject_name', 'subject_code'],
+ },
+ ],
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ order: [['display_order', 'ASC'], ['chapter_number', 'ASC']],
+ });
+
+ const result = {
+ chapters: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ await cacheUtils.set(cacheKey, result, 1800);
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get chapter by ID
+ */
+ async getChapterById(req, res, next) {
+ try {
+ const { id } = req.params;
+ const { include_lessons } = req.query;
+
+ const cacheKey = `chapter:${id}:${include_lessons || 'no'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const includeOptions = [
+ {
+ model: Subject,
+ as: 'subject',
+ attributes: ['id', 'subject_name', 'subject_code', 'description'],
+ },
+ ];
+
+ if (include_lessons === 'true') {
+ includeOptions.push({
+ model: Lesson,
+ as: 'lessons',
+ where: { is_published: true },
+ required: false,
+ order: [['display_order', 'ASC']],
+ });
+ }
+
+ const chapter = await Chapter.findByPk(id, {
+ include: includeOptions,
+ });
+
+ if (!chapter) {
+ return res.status(404).json({
+ success: false,
+ message: 'Chapter not found',
+ });
+ }
+
+ await cacheUtils.set(cacheKey, chapter, 3600);
+
+ res.json({
+ success: true,
+ data: chapter,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Create new chapter
+ */
+ async createChapter(req, res, next) {
+ try {
+ const {
+ subject_id,
+ chapter_number,
+ chapter_title,
+ chapter_description,
+ duration_minutes,
+ is_published,
+ display_order,
+ } = req.body;
+
+ // Validate required fields
+ if (!subject_id || !chapter_number || !chapter_title) {
+ return res.status(400).json({
+ success: false,
+ message: 'subject_id, chapter_number, and chapter_title are required',
+ });
+ }
+
+ // Check if subject exists
+ const subject = await Subject.findByPk(subject_id);
+ if (!subject) {
+ return res.status(404).json({
+ success: false,
+ message: 'Subject not found',
+ });
+ }
+
+ const chapter = await Chapter.create({
+ subject_id,
+ chapter_number,
+ chapter_title,
+ chapter_description,
+ duration_minutes,
+ is_published: is_published !== undefined ? is_published : false,
+ display_order: display_order || 0,
+ });
+
+ // Clear cache
+ await cacheUtils.deletePattern('chapters:*');
+
+ res.status(201).json({
+ success: true,
+ data: chapter,
+ message: 'Chapter created successfully',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Update chapter
+ */
+ async updateChapter(req, res, next) {
+ try {
+ const { id } = req.params;
+ const {
+ chapter_number,
+ chapter_title,
+ chapter_description,
+ duration_minutes,
+ is_published,
+ display_order,
+ } = req.body;
+
+ const chapter = await Chapter.findByPk(id);
+
+ if (!chapter) {
+ return res.status(404).json({
+ success: false,
+ message: 'Chapter not found',
+ });
+ }
+
+ await chapter.update({
+ ...(chapter_number !== undefined && { chapter_number }),
+ ...(chapter_title !== undefined && { chapter_title }),
+ ...(chapter_description !== undefined && { chapter_description }),
+ ...(duration_minutes !== undefined && { duration_minutes }),
+ ...(is_published !== undefined && { is_published }),
+ ...(display_order !== undefined && { display_order }),
+ });
+
+ // Clear cache
+ await cacheUtils.deletePattern('chapters:*');
+ await cacheUtils.deletePattern(`chapter:${id}*`);
+
+ res.json({
+ success: true,
+ data: chapter,
+ message: 'Chapter updated successfully',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Delete chapter
+ */
+ async deleteChapter(req, res, next) {
+ try {
+ const { id } = req.params;
+
+ const chapter = await Chapter.findByPk(id);
+
+ if (!chapter) {
+ return res.status(404).json({
+ success: false,
+ message: 'Chapter not found',
+ });
+ }
+
+ // Check if chapter has lessons
+ const lessonsCount = await Lesson.count({ where: { chapter_id: id } });
+ if (lessonsCount > 0) {
+ return res.status(400).json({
+ success: false,
+ message: `Cannot delete chapter. It has ${lessonsCount} lesson(s). Delete lessons first.`,
+ });
+ }
+
+ await chapter.destroy();
+
+ // Clear cache
+ await cacheUtils.deletePattern('chapters:*');
+ await cacheUtils.deletePattern(`chapter:${id}*`);
+
+ res.json({
+ success: true,
+ message: 'Chapter deleted successfully',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get lessons in a chapter
+ */
+ async getLessonsByChapter(req, res, next) {
+ try {
+ const { id } = req.params;
+ const { page = 1, limit = 20, is_published } = req.query;
+ const offset = (page - 1) * limit;
+
+ const cacheKey = `chapter:${id}:lessons:${page}:${limit}:${is_published || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const chapter = await Chapter.findByPk(id);
+ if (!chapter) {
+ return res.status(404).json({
+ success: false,
+ message: 'Chapter not found',
+ });
+ }
+
+ const where = { chapter_id: id };
+ if (is_published !== undefined) where.is_published = is_published === 'true';
+
+ const { count, rows } = await Lesson.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ order: [['display_order', 'ASC'], ['lesson_number', 'ASC']],
+ });
+
+ const result = {
+ chapter: {
+ id: chapter.id,
+ chapter_title: chapter.chapter_title,
+ chapter_number: chapter.chapter_number,
+ },
+ lessons: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ await cacheUtils.set(cacheKey, result, 1800);
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new ChapterController();
diff --git a/controllers/classController.js b/controllers/classController.js
new file mode 100644
index 0000000..5837da7
--- /dev/null
+++ b/controllers/classController.js
@@ -0,0 +1,364 @@
+const { Class } = require('../models');
+const { StudentDetail, UserProfile, UsersAuth, School } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob } = require('../config/bullmq');
+
+/**
+ * Class Controller - Quản lý thông tin lớp học
+ */
+class ClassController {
+ /**
+ * Get all classes with pagination and caching
+ */
+ async getAllClasses(req, res, next) {
+ try {
+ const { page = 1, limit = 20, school_id, grade_level, academic_year_id } = req.query;
+ const offset = (page - 1) * limit;
+
+ // Generate cache key
+ const cacheKey = `classes:list:${page}:${limit}:${school_id || 'all'}:${grade_level || 'all'}:${academic_year_id || 'all'}`;
+
+ // Try to get from cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Build query conditions
+ const where = {};
+ if (school_id) where.school_id = school_id;
+ if (grade_level) where.grade_level = parseInt(grade_level);
+ if (academic_year_id) where.academic_year_id = academic_year_id;
+
+ // Query from database (through ProxySQL)
+ const { count, rows } = await Class.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ order: [['class_name', 'ASC']],
+ });
+
+ const result = {
+ classes: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ // Cache the result
+ await cacheUtils.set(cacheKey, result, 1800); // Cache for 30 minutes
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get class by ID with caching
+ */
+ async getClassById(req, res, next) {
+ try {
+ const { id } = req.params;
+ const cacheKey = `class:${id}`;
+
+ // Try cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Query from database
+ const classData = await Class.findByPk(id);
+
+ if (!classData) {
+ return res.status(404).json({
+ success: false,
+ message: 'Class not found',
+ });
+ }
+
+ // Cache the result
+ await cacheUtils.set(cacheKey, classData, 3600); // Cache for 1 hour
+
+ res.json({
+ success: true,
+ data: classData,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Create new class (async via BullMQ)
+ */
+ async createClass(req, res, next) {
+ try {
+ const classData = req.body;
+
+ // Add to job queue for async processing
+ const job = await addDatabaseWriteJob('create', 'Class', classData);
+
+ // Invalidate related caches
+ await cacheUtils.deletePattern('classes:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Class creation job queued',
+ jobId: job.id,
+ data: classData,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Update class (async via BullMQ)
+ */
+ async updateClass(req, res, next) {
+ try {
+ const { id } = req.params;
+ const updates = req.body;
+
+ // Add to job queue for async processing
+ const job = await addDatabaseWriteJob('update', 'Class', {
+ id,
+ updates,
+ });
+
+ // Invalidate related caches
+ await cacheUtils.delete(`class:${id}`);
+ await cacheUtils.deletePattern('classes:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Class update job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Delete class (async via BullMQ)
+ */
+ async deleteClass(req, res, next) {
+ try {
+ const { id } = req.params;
+
+ // Add to job queue for async processing
+ const job = await addDatabaseWriteJob('delete', 'Class', { id });
+
+ // Invalidate related caches
+ await cacheUtils.delete(`class:${id}`);
+ await cacheUtils.deletePattern('classes:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Class deletion job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get class statistics
+ */
+ async getClassStatistics(req, res, next) {
+ try {
+ const { school_id } = req.query;
+ const cacheKey = `class:stats:${school_id || 'all'}`;
+
+ // Try cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = {};
+ if (school_id) where.school_id = school_id;
+
+ const stats = {
+ totalClasses: await Class.count({ where }),
+ byGradeLevel: await Class.findAll({
+ where,
+ attributes: [
+ 'grade_level',
+ [require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
+ ],
+ group: ['grade_level'],
+ raw: true,
+ }),
+ };
+
+ // Cache for 1 hour
+ await cacheUtils.set(cacheKey, stats, 3600);
+
+ res.json({
+ success: true,
+ data: stats,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get class datatypes
+ */
+ async getClassDatatypes(req, res, next) {
+ try {
+ const datatypes = Class.rawAttributes;
+ res.json({
+ success: true,
+ data: datatypes,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get students in a class
+ */
+ async getStudentsByClass(req, res, next) {
+ try {
+ const { id } = req.params;
+ const { page = 1, limit = 20, status = 'active' } = req.query;
+ const offset = (page - 1) * limit;
+
+ // Generate cache key
+ const cacheKey = `class:${id}:students:${page}:${limit}:${status}`;
+
+ // Try cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Check if class exists and get school info
+ const classData = await Class.findByPk(id, {
+ include: [
+ {
+ model: School,
+ as: 'school',
+ attributes: ['school_name', 'school_code', 'address', 'city', 'district'],
+ },
+ ],
+ });
+ if (!classData) {
+ return res.status(404).json({
+ success: false,
+ message: 'Class not found',
+ });
+ }
+
+ // Query students
+ const { count, rows: students } = await StudentDetail.findAndCountAll({
+ where: {
+ current_class_id: id,
+ status: status,
+ },
+ include: [
+ {
+ model: UserProfile,
+ as: 'profile',
+ attributes: [
+ 'full_name',
+ 'first_name',
+ 'last_name',
+ 'date_of_birth',
+ 'phone',
+ 'address',
+ 'city',
+ 'district',
+ ],
+ include: [
+ {
+ model: UsersAuth,
+ as: 'auth',
+ attributes: ['username', 'email', 'is_active'],
+ },
+ {
+ model: School,
+ as: 'school',
+ attributes: ['school_name', 'school_code'],
+ },
+ ],
+ },
+ ],
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ order: [[{ model: UserProfile, as: 'profile' }, 'full_name', 'ASC']],
+ });
+
+ const result = {
+ class: {
+ id: classData.id,
+ class_name: classData.class_name,
+ class_code: classData.class_code,
+ grade_level: classData.grade_level,
+ max_students: classData.max_students,
+ current_students: classData.current_students,
+ school: classData.school ? {
+ school_name: classData.school.school_name,
+ school_code: classData.school.school_code,
+ address: classData.school.address,
+ city: classData.school.city,
+ district: classData.school.district,
+ } : null,
+ },
+ students: students,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ // Cache for 10 minutes
+ await cacheUtils.set(cacheKey, result, 600);
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new ClassController();
diff --git a/controllers/gameController.js b/controllers/gameController.js
new file mode 100644
index 0000000..07ce948
--- /dev/null
+++ b/controllers/gameController.js
@@ -0,0 +1,374 @@
+const { Game } = require('../models');
+const { cacheUtils } = require('../config/redis');
+
+/**
+ * Game Controller - Quản lý trò chơi giáo dục
+ */
+class GameController {
+ /**
+ * Get all games with pagination
+ */
+ async getAllGames(req, res, next) {
+ try {
+ const { page = 1, limit = 20, type, is_active, is_premium, difficulty_level } = req.query;
+ const offset = (page - 1) * limit;
+
+ const cacheKey = `games:list:${page}:${limit}:${type || 'all'}:${is_active || 'all'}:${is_premium || 'all'}:${difficulty_level || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = {};
+ if (type) where.type = type;
+ if (is_active !== undefined) where.is_active = is_active === 'true';
+ if (is_premium !== undefined) where.is_premium = is_premium === 'true';
+ if (difficulty_level) where.difficulty_level = difficulty_level;
+
+ const { count, rows } = await Game.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ order: [['display_order', 'ASC'], ['rating', 'DESC']],
+ });
+
+ const result = {
+ games: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ await cacheUtils.set(cacheKey, result, 1800);
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get game by ID
+ */
+ async getGameById(req, res, next) {
+ try {
+ const { id } = req.params;
+ const cacheKey = `game:${id}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const game = await Game.findByPk(id);
+
+ if (!game) {
+ return res.status(404).json({
+ success: false,
+ message: 'Game not found',
+ });
+ }
+
+ await cacheUtils.set(cacheKey, game, 3600);
+
+ res.json({
+ success: true,
+ data: game,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Create new game
+ */
+ async createGame(req, res, next) {
+ try {
+ const {
+ title,
+ description,
+ url,
+ thumbnail,
+ type,
+ config,
+ is_active,
+ is_premium,
+ min_grade,
+ max_grade,
+ difficulty_level,
+ display_order,
+ } = req.body;
+
+ // Validate required fields
+ if (!title || !url || !type) {
+ return res.status(400).json({
+ success: false,
+ message: 'title, url, and type are required',
+ });
+ }
+
+ const game = await Game.create({
+ title,
+ description,
+ url,
+ thumbnail,
+ type,
+ config: config || {},
+ is_active: is_active !== undefined ? is_active : true,
+ is_premium: is_premium !== undefined ? is_premium : false,
+ min_grade,
+ max_grade,
+ difficulty_level,
+ display_order: display_order || 0,
+ play_count: 0,
+ });
+
+ // Clear cache
+ await cacheUtils.deletePattern('games:*');
+
+ res.status(201).json({
+ success: true,
+ data: game,
+ message: 'Game created successfully',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Update game
+ */
+ async updateGame(req, res, next) {
+ try {
+ const { id } = req.params;
+ const {
+ title,
+ description,
+ url,
+ thumbnail,
+ type,
+ config,
+ is_active,
+ is_premium,
+ min_grade,
+ max_grade,
+ difficulty_level,
+ display_order,
+ rating,
+ } = req.body;
+
+ const game = await Game.findByPk(id);
+
+ if (!game) {
+ return res.status(404).json({
+ success: false,
+ message: 'Game not found',
+ });
+ }
+
+ await game.update({
+ ...(title !== undefined && { title }),
+ ...(description !== undefined && { description }),
+ ...(url !== undefined && { url }),
+ ...(thumbnail !== undefined && { thumbnail }),
+ ...(type !== undefined && { type }),
+ ...(config !== undefined && { config }),
+ ...(is_active !== undefined && { is_active }),
+ ...(is_premium !== undefined && { is_premium }),
+ ...(min_grade !== undefined && { min_grade }),
+ ...(max_grade !== undefined && { max_grade }),
+ ...(difficulty_level !== undefined && { difficulty_level }),
+ ...(display_order !== undefined && { display_order }),
+ ...(rating !== undefined && { rating }),
+ });
+
+ // Clear cache
+ await cacheUtils.deletePattern('games:*');
+ await cacheUtils.deletePattern(`game:${id}`);
+
+ res.json({
+ success: true,
+ data: game,
+ message: 'Game updated successfully',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Delete game
+ */
+ async deleteGame(req, res, next) {
+ try {
+ const { id } = req.params;
+
+ const game = await Game.findByPk(id);
+
+ if (!game) {
+ return res.status(404).json({
+ success: false,
+ message: 'Game not found',
+ });
+ }
+
+ await game.destroy();
+
+ // Clear cache
+ await cacheUtils.deletePattern('games:*');
+ await cacheUtils.deletePattern(`game:${id}`);
+
+ res.json({
+ success: true,
+ message: 'Game deleted successfully',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Increment play count
+ */
+ async incrementPlayCount(req, res, next) {
+ try {
+ const { id } = req.params;
+
+ const game = await Game.findByPk(id);
+
+ if (!game) {
+ return res.status(404).json({
+ success: false,
+ message: 'Game not found',
+ });
+ }
+
+ await game.increment('play_count');
+ await game.reload();
+
+ // Clear cache
+ await cacheUtils.deletePattern(`game:${id}`);
+
+ res.json({
+ success: true,
+ data: {
+ id: game.id,
+ play_count: game.play_count,
+ },
+ message: 'Play count incremented',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get games by type (for lesson matching)
+ */
+ async getGamesByType(req, res, next) {
+ try {
+ const { type } = req.params;
+ const { only_active = 'true' } = req.query;
+
+ const cacheKey = `games:type:${type}:${only_active}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = { type };
+ if (only_active === 'true') where.is_active = true;
+
+ const games = await Game.findAll({
+ where,
+ order: [['display_order', 'ASC'], ['rating', 'DESC']],
+ });
+
+ await cacheUtils.set(cacheKey, games, 3600);
+
+ res.json({
+ success: true,
+ data: games,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get game statistics
+ */
+ async getGameStats(req, res, next) {
+ try {
+ const cacheKey = 'games:stats';
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const [totalGames, activeGames, premiumGames, totalPlays] = await Promise.all([
+ Game.count(),
+ Game.count({ where: { is_active: true } }),
+ Game.count({ where: { is_premium: true } }),
+ Game.sum('play_count'),
+ ]);
+
+ const topGames = await Game.findAll({
+ where: { is_active: true },
+ order: [['play_count', 'DESC']],
+ limit: 10,
+ attributes: ['id', 'title', 'type', 'play_count', 'rating'],
+ });
+
+ const stats = {
+ totalGames,
+ activeGames,
+ premiumGames,
+ totalPlays: totalPlays || 0,
+ topGames,
+ };
+
+ await cacheUtils.set(cacheKey, stats, 600);
+
+ res.json({
+ success: true,
+ data: stats,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new GameController();
diff --git a/controllers/gradeController.js b/controllers/gradeController.js
new file mode 100644
index 0000000..ca41300
--- /dev/null
+++ b/controllers/gradeController.js
@@ -0,0 +1,264 @@
+const { Grade, GradeItem, GradeSummary, StudentDetail, Subject, AcademicYear } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob, addGradeCalculationJob } = require('../config/bullmq');
+
+/**
+ * Grade Controller - Quản lý điểm số
+ */
+class GradeController {
+ /**
+ * Get grades with pagination
+ */
+ async getAllGrades(req, res, next) {
+ try {
+ const { page = 1, limit = 50, student_id, subject_id, academic_year_id } = req.query;
+ const offset = (page - 1) * limit;
+
+ const cacheKey = `grades:list:${page}:${limit}:${student_id || 'all'}:${subject_id || 'all'}:${academic_year_id || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = {};
+ if (student_id) where.student_id = student_id;
+ if (subject_id) where.subject_id = subject_id;
+ if (academic_year_id) where.academic_year_id = academic_year_id;
+
+ const { count, rows } = await Grade.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ include: [
+ {
+ model: StudentDetail,
+ as: 'student',
+ attributes: ['student_code'],
+ },
+ {
+ model: GradeItem,
+ as: 'gradeItem',
+ attributes: ['item_name', 'max_score'],
+ },
+ ],
+ order: [['created_at', 'DESC']],
+ });
+
+ const result = {
+ grades: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ await cacheUtils.set(cacheKey, result, 1800);
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get grade by ID
+ */
+ async getGradeById(req, res, next) {
+ try {
+ const { id } = req.params;
+ const cacheKey = `grade:${id}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const grade = await Grade.findByPk(id, {
+ include: [
+ {
+ model: StudentDetail,
+ as: 'student',
+ },
+ {
+ model: GradeItem,
+ as: 'gradeItem',
+ },
+ ],
+ });
+
+ if (!grade) {
+ return res.status(404).json({
+ success: false,
+ message: 'Grade not found',
+ });
+ }
+
+ await cacheUtils.set(cacheKey, grade, 3600);
+
+ res.json({
+ success: true,
+ data: grade,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Create new grade (async via BullMQ)
+ */
+ async createGrade(req, res, next) {
+ try {
+ const gradeData = req.body;
+
+ const job = await addDatabaseWriteJob('create', 'Grade', gradeData);
+
+ await cacheUtils.deletePattern('grades:list:*');
+ await cacheUtils.deletePattern(`grade:student:${gradeData.student_id}:*`);
+
+ res.status(202).json({
+ success: true,
+ message: 'Grade creation job queued',
+ jobId: job.id,
+ data: gradeData,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Update grade (async via BullMQ)
+ */
+ async updateGrade(req, res, next) {
+ try {
+ const { id } = req.params;
+ const updates = req.body;
+
+ const job = await addDatabaseWriteJob('update', 'Grade', {
+ id,
+ updates,
+ });
+
+ await cacheUtils.delete(`grade:${id}`);
+ await cacheUtils.deletePattern('grades:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Grade update job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Delete grade (async via BullMQ)
+ */
+ async deleteGrade(req, res, next) {
+ try {
+ const { id } = req.params;
+
+ const job = await addDatabaseWriteJob('delete', 'Grade', { id });
+
+ await cacheUtils.delete(`grade:${id}`);
+ await cacheUtils.deletePattern('grades:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Grade deletion job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get student grade summary
+ */
+ async getStudentGradeSummary(req, res, next) {
+ try {
+ const { student_id, academic_year_id } = req.params;
+ const cacheKey = `grade:student:${student_id}:${academic_year_id}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const summary = await GradeSummary.findOne({
+ where: { student_id, academic_year_id },
+ });
+
+ await cacheUtils.set(cacheKey, summary, 3600);
+
+ res.json({
+ success: true,
+ data: summary,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Calculate student GPA (async via BullMQ)
+ */
+ async calculateGPA(req, res, next) {
+ try {
+ const { student_id, academic_year_id } = req.body;
+
+ const job = await addGradeCalculationJob(student_id, academic_year_id);
+
+ await cacheUtils.deletePattern(`grade:student:${student_id}:*`);
+
+ res.status(202).json({
+ success: true,
+ message: 'GPA calculation job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get grade datatypes
+ */
+ async getGradeDatatypes(req, res, next) {
+ try {
+ const datatypes = Grade.rawAttributes;
+ res.json({
+ success: true,
+ data: datatypes,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new GradeController();
diff --git a/controllers/parentTaskController.js b/controllers/parentTaskController.js
new file mode 100644
index 0000000..0308d63
--- /dev/null
+++ b/controllers/parentTaskController.js
@@ -0,0 +1,391 @@
+const { ParentAssignedTask, GradeItem, StudentDetail, UsersAuth, ParentStudentMap } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob, addNotificationJob } = require('../config/bullmq');
+const { v4: uuidv4 } = require('uuid');
+
+/**
+ * Parent Task Controller - Quản lý bài tập phụ huynh giao cho con
+ */
+class ParentTaskController {
+ /**
+ * POST /api/parent-tasks/assign - Phụ huynh giao bài tập cho con
+ */
+ async assignTask(req, res, next) {
+ try {
+ const { parent_id, student_id, grade_item_id, due_date, message, priority } = req.body;
+
+ if (!parent_id || !student_id || !grade_item_id || !due_date) {
+ return res.status(400).json({
+ success: false,
+ message: 'Missing required fields: parent_id, student_id, grade_item_id, due_date',
+ });
+ }
+
+ // Kiểm tra quyền của phụ huynh
+ const parentMapping = await ParentStudentMap.findOne({
+ where: {
+ parent_id,
+ student_id,
+ },
+ });
+
+ if (!parentMapping) {
+ return res.status(403).json({
+ success: false,
+ message: 'Parent-student relationship not found',
+ });
+ }
+
+ if (!parentMapping.can_assign_tasks) {
+ return res.status(403).json({
+ success: false,
+ message: 'Parent does not have permission to assign tasks',
+ });
+ }
+
+ // Kiểm tra grade_item có tồn tại không
+ const gradeItem = await GradeItem.findByPk(grade_item_id);
+ if (!gradeItem) {
+ return res.status(404).json({
+ success: false,
+ message: 'Grade item not found',
+ });
+ }
+
+ const taskData = {
+ id: uuidv4(),
+ parent_id,
+ student_id,
+ grade_item_id,
+ assigned_at: new Date(),
+ due_date: new Date(due_date),
+ status: 'pending',
+ message: message || null,
+ priority: priority || 'normal',
+ };
+
+ // Async write to DB via BullMQ
+ await addDatabaseWriteJob('create', 'ParentAssignedTask', taskData);
+
+ // Gửi notification cho học sinh
+ await addNotificationJob({
+ user_id: student_id,
+ type: 'parent_task_assigned',
+ title: 'Bài tập mới từ phụ huynh',
+ message: message || `Bạn có bài tập mới: ${gradeItem.item_name}`,
+ data: taskData,
+ });
+
+ // Clear cache
+ await cacheUtils.del(`parent:tasks:${parent_id}`);
+ await cacheUtils.del(`student:tasks:${student_id}`);
+
+ res.status(202).json({
+ success: true,
+ message: 'Task assignment request queued',
+ data: taskData,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * GET /api/parent-tasks/parent/:parent_id - Danh sách bài tập phụ huynh đã giao
+ */
+ async getParentTasks(req, res, next) {
+ try {
+ const { parent_id } = req.params;
+ const { status, student_id } = req.query;
+
+ const cacheKey = `parent:tasks:${parent_id}:${status || 'all'}:${student_id || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = { parent_id };
+ if (status) where.status = status;
+ if (student_id) where.student_id = student_id;
+
+ const tasks = await ParentAssignedTask.findAll({
+ where,
+ include: [
+ {
+ model: StudentDetail,
+ as: 'student',
+ attributes: ['student_code', 'full_name'],
+ },
+ {
+ model: GradeItem,
+ as: 'gradeItem',
+ attributes: ['item_name', 'max_score', 'description'],
+ },
+ ],
+ order: [
+ ['priority', 'DESC'],
+ ['due_date', 'ASC'],
+ ],
+ });
+
+ // Check for overdue tasks
+ const now = new Date();
+ for (const task of tasks) {
+ if (task.status !== 'completed' && new Date(task.due_date) < now) {
+ if (task.status !== 'overdue') {
+ await addDatabaseWriteJob('update', 'ParentAssignedTask', {
+ id: task.id,
+ status: 'overdue',
+ });
+ task.status = 'overdue';
+ }
+ }
+ }
+
+ await cacheUtils.set(cacheKey, tasks, 900); // Cache 15 min
+
+ res.json({
+ success: true,
+ data: tasks,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * GET /api/parent-tasks/student/:student_id - Danh sách bài tập của học sinh
+ */
+ async getStudentTasks(req, res, next) {
+ try {
+ const { student_id } = req.params;
+ const { status } = req.query;
+
+ const cacheKey = `student:tasks:${student_id}:${status || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = { student_id };
+ if (status) where.status = status;
+
+ const tasks = await ParentAssignedTask.findAll({
+ where,
+ include: [
+ {
+ model: UsersAuth,
+ as: 'parent',
+ attributes: ['username', 'full_name'],
+ },
+ {
+ model: GradeItem,
+ as: 'gradeItem',
+ attributes: ['item_name', 'max_score', 'description'],
+ },
+ ],
+ order: [
+ ['priority', 'DESC'],
+ ['due_date', 'ASC'],
+ ],
+ });
+
+ await cacheUtils.set(cacheKey, tasks, 900); // Cache 15 min
+
+ res.json({
+ success: true,
+ data: tasks,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * PUT /api/parent-tasks/:id/complete - Học sinh hoàn thành bài tập
+ */
+ async completeTask(req, res, next) {
+ try {
+ const { id } = req.params;
+ const { student_notes } = req.body;
+
+ const task = await ParentAssignedTask.findByPk(id);
+
+ if (!task) {
+ return res.status(404).json({
+ success: false,
+ message: 'Task not found',
+ });
+ }
+
+ if (task.status === 'completed') {
+ return res.status(400).json({
+ success: false,
+ message: 'Task already completed',
+ });
+ }
+
+ await addDatabaseWriteJob('update', 'ParentAssignedTask', {
+ id,
+ status: 'completed',
+ completion_date: new Date(),
+ student_notes: student_notes || null,
+ });
+
+ // Gửi notification cho phụ huynh
+ await addNotificationJob({
+ user_id: task.parent_id,
+ type: 'parent_task_completed',
+ title: 'Bài tập đã hoàn thành',
+ message: `Con bạn đã hoàn thành bài tập`,
+ data: { task_id: id },
+ });
+
+ // Clear cache
+ await cacheUtils.del(`parent:tasks:${task.parent_id}:*`);
+ await cacheUtils.del(`student:tasks:${task.student_id}:*`);
+
+ res.status(202).json({
+ success: true,
+ message: 'Task completion request queued',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * PUT /api/parent-tasks/:id/status - Cập nhật trạng thái bài tập
+ */
+ async updateTaskStatus(req, res, next) {
+ try {
+ const { id } = req.params;
+ const { status } = req.body;
+
+ const validStatuses = ['pending', 'in_progress', 'completed', 'overdue'];
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({
+ success: false,
+ message: `Invalid status. Must be one of: ${validStatuses.join(', ')}`,
+ });
+ }
+
+ const task = await ParentAssignedTask.findByPk(id);
+ if (!task) {
+ return res.status(404).json({
+ success: false,
+ message: 'Task not found',
+ });
+ }
+
+ await addDatabaseWriteJob('update', 'ParentAssignedTask', {
+ id,
+ status,
+ });
+
+ // Clear cache
+ await cacheUtils.del(`parent:tasks:${task.parent_id}:*`);
+ await cacheUtils.del(`student:tasks:${task.student_id}:*`);
+
+ res.status(202).json({
+ success: true,
+ message: 'Task status update queued',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * DELETE /api/parent-tasks/:id - Xóa bài tập (chỉ phụ huynh mới xóa được)
+ */
+ async deleteTask(req, res, next) {
+ try {
+ const { id } = req.params;
+ const { parent_id } = req.body; // Xác thực parent_id
+
+ const task = await ParentAssignedTask.findByPk(id);
+
+ if (!task) {
+ return res.status(404).json({
+ success: false,
+ message: 'Task not found',
+ });
+ }
+
+ if (task.parent_id !== parent_id) {
+ return res.status(403).json({
+ success: false,
+ message: 'Only the parent who assigned the task can delete it',
+ });
+ }
+
+ await addDatabaseWriteJob('delete', 'ParentAssignedTask', { id });
+
+ // Clear cache
+ await cacheUtils.del(`parent:tasks:${task.parent_id}:*`);
+ await cacheUtils.del(`student:tasks:${task.student_id}:*`);
+
+ res.status(202).json({
+ success: true,
+ message: 'Task deletion request queued',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * GET /api/parent-tasks/stats - Thống kê bài tập
+ */
+ async getTaskStats(req, res, next) {
+ try {
+ const { parent_id, student_id } = req.query;
+
+ const cacheKey = `parent:tasks:stats:${parent_id || 'all'}:${student_id || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = {};
+ if (parent_id) where.parent_id = parent_id;
+ if (student_id) where.student_id = student_id;
+
+ const stats = await ParentAssignedTask.findAll({
+ where,
+ attributes: [
+ 'status',
+ [require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
+ ],
+ group: ['status'],
+ });
+
+ await cacheUtils.set(cacheKey, stats, 300); // Cache 5 min
+
+ res.json({
+ success: true,
+ data: stats,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new ParentTaskController();
diff --git a/controllers/roomController.js b/controllers/roomController.js
new file mode 100644
index 0000000..4f06ee9
--- /dev/null
+++ b/controllers/roomController.js
@@ -0,0 +1,219 @@
+const { Room, School } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob } = require('../config/bullmq');
+
+/**
+ * Room Controller - Quản lý phòng học
+ */
+class RoomController {
+ /**
+ * Get all rooms with pagination and caching
+ */
+ async getAllRooms(req, res, next) {
+ try {
+ const { page = 1, limit = 50, school_id, room_type } = req.query;
+ const offset = (page - 1) * limit;
+
+ const cacheKey = `rooms:list:${page}:${limit}:${school_id || 'all'}:${room_type || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = {};
+ if (school_id) where.school_id = school_id;
+ if (room_type) where.room_type = room_type;
+
+ const { count, rows } = await Room.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ order: [['school_id', 'ASC'], ['floor', 'ASC'], ['room_code', 'ASC']],
+ });
+
+ const result = {
+ rooms: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ await cacheUtils.set(cacheKey, result, 3600);
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get room by ID
+ */
+ async getRoomById(req, res, next) {
+ try {
+ const { id } = req.params;
+ const cacheKey = `room:${id}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const room = await Room.findByPk(id);
+
+ if (!room) {
+ return res.status(404).json({
+ success: false,
+ message: 'Room not found',
+ });
+ }
+
+ await cacheUtils.set(cacheKey, room, 7200);
+
+ res.json({
+ success: true,
+ data: room,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Create new room (async via BullMQ)
+ */
+ async createRoom(req, res, next) {
+ try {
+ const roomData = req.body;
+
+ const job = await addDatabaseWriteJob('create', 'Room', roomData);
+
+ await cacheUtils.deletePattern('rooms:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Room creation job queued',
+ jobId: job.id,
+ data: roomData,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Update room (async via BullMQ)
+ */
+ async updateRoom(req, res, next) {
+ try {
+ const { id } = req.params;
+ const updates = req.body;
+
+ const job = await addDatabaseWriteJob('update', 'Room', {
+ id,
+ updates,
+ });
+
+ await cacheUtils.delete(`room:${id}`);
+ await cacheUtils.deletePattern('rooms:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Room update job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Delete room (async via BullMQ)
+ */
+ async deleteRoom(req, res, next) {
+ try {
+ const { id } = req.params;
+
+ const job = await addDatabaseWriteJob('delete', 'Room', { id });
+
+ await cacheUtils.delete(`room:${id}`);
+ await cacheUtils.deletePattern('rooms:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Room deletion job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get rooms by school
+ */
+ async getRoomsBySchool(req, res, next) {
+ try {
+ const { school_id } = req.params;
+ const cacheKey = `rooms:school:${school_id}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const rooms = await Room.findAll({
+ where: { school_id },
+ order: [['floor', 'ASC'], ['room_code', 'ASC']],
+ });
+
+ await cacheUtils.set(cacheKey, rooms, 7200);
+
+ res.json({
+ success: true,
+ data: rooms,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get room datatypes
+ */
+ async getRoomDatatypes(req, res, next) {
+ try {
+ const datatypes = Room.rawAttributes;
+ res.json({
+ success: true,
+ data: datatypes,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new RoomController();
diff --git a/controllers/schoolController.js b/controllers/schoolController.js
new file mode 100644
index 0000000..654cc2b
--- /dev/null
+++ b/controllers/schoolController.js
@@ -0,0 +1,302 @@
+const { School } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob } = require('../config/bullmq');
+
+/**
+ * School Controller - Quản lý thông tin trường học
+ */
+class SchoolController {
+ /**
+ * Get all schools with pagination and caching
+ */
+ async getAllSchools(req, res, next) {
+ try {
+ const { page = 1, limit = 20, type, city, is_active } = req.query;
+ const offset = (page - 1) * limit;
+
+ // Generate cache key
+ const cacheKey = `schools:list:${page}:${limit}:${type || 'all'}:${city || 'all'}:${is_active || 'all'}`;
+
+ // Try to get from cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Build query conditions
+ const where = {};
+ if (type) where.school_type = type;
+ if (city) where.city = city;
+ if (is_active !== undefined) {
+ where.is_active = is_active === 'true' || is_active === true;
+ }
+
+ // Query from database (through ProxySQL)
+ const { count, rows } = await School.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ order: [['school_name', 'ASC']],
+ attributes: { exclude: ['config'] }, // Exclude sensitive data
+ });
+
+ const result = {
+ schools: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ // Cache the result
+ await cacheUtils.set(cacheKey, result, 3600); // Cache for 1 hour
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+ /**
+ * Get school by ID with caching
+ */
+ async getSchoolById(req, res, next) {
+ try {
+ const { id } = req.params;
+ const cacheKey = `school:${id}`;
+
+ // Try cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Query from database
+ const school = await School.findByPk(id);
+
+ if (!school) {
+ return res.status(404).json({
+ success: false,
+ message: 'School not found',
+ });
+ }
+
+ // Cache the result
+ await cacheUtils.set(cacheKey, school, 7200); // Cache for 2 hours
+
+ res.json({
+ success: true,
+ data: school,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Create new school - Push to BullMQ
+ */
+ async createSchool(req, res, next) {
+ try {
+ const schoolData = req.body;
+ const userId = req.user?.id; // From auth middleware
+
+ // Validate required fields (you can use Joi for this)
+ if (!schoolData.school_code || !schoolData.school_name || !schoolData.school_type) {
+ return res.status(400).json({
+ success: false,
+ message: 'Missing required fields',
+ });
+ }
+
+ // Add job to BullMQ queue
+ const job = await addDatabaseWriteJob(
+ 'create',
+ 'School',
+ schoolData,
+ { userId, priority: 3 }
+ );
+
+ res.status(202).json({
+ success: true,
+ message: 'School creation queued',
+ jobId: job.id,
+ data: {
+ school_code: schoolData.school_code,
+ school_name: schoolData.school_name,
+ },
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Update school - Push to BullMQ
+ */
+ async updateSchool(req, res, next) {
+ try {
+ const { id } = req.params;
+ const updateData = req.body;
+ const userId = req.user?.id;
+
+ // Check if school exists (from cache or DB)
+ const cacheKey = `school:${id}`;
+ let school = await cacheUtils.get(cacheKey);
+
+ if (!school) {
+ school = await School.findByPk(id);
+ if (!school) {
+ return res.status(404).json({
+ success: false,
+ message: 'School not found',
+ });
+ }
+ }
+
+ // Add update job to BullMQ
+ const job = await addDatabaseWriteJob(
+ 'update',
+ 'School',
+ { id, ...updateData },
+ { userId, priority: 3 }
+ );
+
+ // Invalidate cache
+ await cacheUtils.delete(cacheKey);
+ await cacheUtils.deletePattern('schools:list:*');
+
+ res.json({
+ success: true,
+ message: 'School update queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Delete school - Push to BullMQ
+ */
+ async deleteSchool(req, res, next) {
+ try {
+ const { id } = req.params;
+ const userId = req.user?.id;
+
+ // Check if school exists
+ const school = await School.findByPk(id);
+ if (!school) {
+ return res.status(404).json({
+ success: false,
+ message: 'School not found',
+ });
+ }
+
+ // Soft delete - just mark as inactive
+ const job = await addDatabaseWriteJob(
+ 'update',
+ 'School',
+ { id, is_active: false },
+ { userId, priority: 3 }
+ );
+
+ // Invalidate cache
+ await cacheUtils.delete(`school:${id}`);
+ await cacheUtils.deletePattern('schools:list:*');
+
+ res.json({
+ success: true,
+ message: 'School deletion queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get school statistics
+ */
+ async getSchoolStats(req, res, next) {
+ try {
+ const { id } = req.params;
+ const cacheKey = `school:${id}:stats`;
+
+ // Try cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Get school with related data
+ const school = await School.findByPk(id, {
+ include: [
+ { association: 'classes', attributes: ['id'] },
+ { association: 'rooms', attributes: ['id'] },
+ ],
+ });
+
+ if (!school) {
+ return res.status(404).json({
+ success: false,
+ message: 'School not found',
+ });
+ }
+
+ const stats = {
+ school_id: id,
+ school_name: school.school_name,
+ total_classes: school.classes?.length || 0,
+ total_rooms: school.rooms?.length || 0,
+ capacity: school.capacity,
+ };
+
+ // Cache for 30 minutes
+ await cacheUtils.set(cacheKey, stats, 1800);
+
+ res.json({
+ success: true,
+ data: stats,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get school datatypes
+ */
+ async getSchoolDatatypes(req, res, next) {
+ try {
+ const datatypes = School.rawAttributes;
+ res.json({
+ success: true,
+ data: datatypes,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new SchoolController();
diff --git a/controllers/studentController.js b/controllers/studentController.js
new file mode 100644
index 0000000..3d42652
--- /dev/null
+++ b/controllers/studentController.js
@@ -0,0 +1,211 @@
+const { StudentDetail, UserProfile, UsersAuth, Class } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob } = require('../config/bullmq');
+
+/**
+ * Student Controller - Quản lý học sinh
+ */
+class StudentController {
+ /**
+ * Get all students with pagination and caching
+ */
+ async getAllStudents(req, res, next) {
+ try {
+ const { page = 1, limit = 20, status, class_id } = req.query;
+ const offset = (page - 1) * limit;
+
+ const cacheKey = `students:list:${page}:${limit}:${status || 'all'}:${class_id || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = {};
+ if (status) where.status = status;
+ if (class_id) where.current_class_id = class_id;
+
+ const { count, rows } = await StudentDetail.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ include: [
+ {
+ model: UserProfile,
+ as: 'profile',
+ attributes: ['full_name', 'phone', 'date_of_birth', 'gender'],
+ },
+ {
+ model: Class,
+ as: 'currentClass',
+ attributes: ['class_code', 'class_name', 'grade_level'],
+ },
+ ],
+ order: [['created_at', 'DESC']],
+ });
+
+ const result = {
+ students: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ await cacheUtils.set(cacheKey, result, 1800);
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get student by ID
+ */
+ async getStudentById(req, res, next) {
+ try {
+ const { id } = req.params;
+ const cacheKey = `student:${id}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const student = await StudentDetail.findByPk(id, {
+ include: [
+ {
+ model: UserProfile,
+ as: 'profile',
+ },
+ {
+ model: Class,
+ as: 'currentClass',
+ },
+ ],
+ });
+
+ if (!student) {
+ return res.status(404).json({
+ success: false,
+ message: 'Student not found',
+ });
+ }
+
+ await cacheUtils.set(cacheKey, student, 3600);
+
+ res.json({
+ success: true,
+ data: student,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Create new student (async via BullMQ)
+ */
+ async createStudent(req, res, next) {
+ try {
+ const studentData = req.body;
+
+ const job = await addDatabaseWriteJob('create', 'StudentDetail', studentData);
+
+ await cacheUtils.deletePattern('students:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Student creation job queued',
+ jobId: job.id,
+ data: studentData,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Update student (async via BullMQ)
+ */
+ async updateStudent(req, res, next) {
+ try {
+ const { id } = req.params;
+ const updates = req.body;
+
+ const job = await addDatabaseWriteJob('update', 'StudentDetail', {
+ id,
+ updates,
+ });
+
+ await cacheUtils.delete(`student:${id}`);
+ await cacheUtils.deletePattern('students:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Student update job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Delete student (update status to dropped)
+ */
+ async deleteStudent(req, res, next) {
+ try {
+ const { id } = req.params;
+
+ const job = await addDatabaseWriteJob('update', 'StudentDetail', {
+ id,
+ updates: { status: 'dropped' },
+ });
+
+ await cacheUtils.delete(`student:${id}`);
+ await cacheUtils.deletePattern('students:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Student deletion job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get student datatypes
+ */
+ async getStudentDatatypes(req, res, next) {
+ try {
+ const datatypes = StudentDetail.rawAttributes;
+ res.json({
+ success: true,
+ data: datatypes,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new StudentController();
diff --git a/controllers/subjectController.js b/controllers/subjectController.js
new file mode 100644
index 0000000..6ac13b7
--- /dev/null
+++ b/controllers/subjectController.js
@@ -0,0 +1,343 @@
+const { Subject, Chapter } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob } = require('../config/bullmq');
+
+/**
+ * Subject Controller - Quản lý môn học
+ */
+class SubjectController {
+ /**
+ * Get all subjects with pagination and caching
+ */
+ async getAllSubjects(req, res, next) {
+ try {
+ const { page = 1, limit = 50, is_active } = req.query;
+ const offset = (page - 1) * limit;
+
+ // Generate cache key
+ const cacheKey = `subjects:list:${page}:${limit}:${is_active || 'all'}`;
+
+ // Try to get from cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Build query conditions
+ const where = {};
+ if (is_active !== undefined) where.is_active = is_active === 'true';
+
+ // Query from database (through ProxySQL)
+ const { count, rows } = await Subject.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ order: [['subject_code', 'ASC']],
+ });
+
+ const result = {
+ subjects: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ // Cache the result
+ await cacheUtils.set(cacheKey, result, 7200); // Cache for 2 hours
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get subject by ID with caching
+ */
+ async getSubjectById(req, res, next) {
+ try {
+ const { id } = req.params;
+ const cacheKey = `subject:${id}`;
+
+ // Try cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Query from database
+ const subject = await Subject.findByPk(id);
+
+ if (!subject) {
+ return res.status(404).json({
+ success: false,
+ message: 'Subject not found',
+ });
+ }
+
+ // Cache the result
+ await cacheUtils.set(cacheKey, subject, 7200); // Cache for 2 hours
+
+ res.json({
+ success: true,
+ data: subject,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get subject by code with caching
+ */
+ async getSubjectByCode(req, res, next) {
+ try {
+ const { code } = req.params;
+ const cacheKey = `subject:code:${code}`;
+
+ // Try cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Query from database
+ const subject = await Subject.findOne({
+ where: { subject_code: code },
+ });
+
+ if (!subject) {
+ return res.status(404).json({
+ success: false,
+ message: 'Subject not found',
+ });
+ }
+
+ // Cache the result
+ await cacheUtils.set(cacheKey, subject, 7200); // Cache for 2 hours
+
+ res.json({
+ success: true,
+ data: subject,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Create new subject (async via BullMQ)
+ */
+ async createSubject(req, res, next) {
+ try {
+ const subjectData = req.body;
+
+ // Add to job queue for async processing
+ const job = await addDatabaseWriteJob('create', 'Subject', subjectData);
+
+ // Note: Cache will be invalidated by worker after successful creation
+
+ res.status(202).json({
+ success: true,
+ message: 'Subject creation job queued',
+ jobId: job.id,
+ data: subjectData,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Update subject (async via BullMQ)
+ */
+ async updateSubject(req, res, next) {
+ try {
+ const { id } = req.params;
+ const updates = req.body;
+
+ // Add to job queue for async processing
+ const job = await addDatabaseWriteJob('update', 'Subject', {
+ id,
+ updates,
+ });
+
+ // Note: Cache will be invalidated by worker after successful update
+
+ res.status(202).json({
+ success: true,
+ message: 'Subject update job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Delete subject (async via BullMQ)
+ */
+ async deleteSubject(req, res, next) {
+ try {
+ const { id } = req.params;
+
+ // Add to job queue for async processing
+ const job = await addDatabaseWriteJob('delete', 'Subject', { id });
+
+ // Note: Cache will be invalidated by worker after successful deletion
+
+ res.status(202).json({
+ success: true,
+ message: 'Subject deletion job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get active subjects (frequently used)
+ */
+ async getActiveSubjects(req, res, next) {
+ try {
+ const cacheKey = 'subjects:active';
+
+ // Try cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Query from database
+ const subjects = await Subject.findAll({
+ where: { is_active: true },
+ order: [['subject_code', 'ASC']],
+ });
+
+ // Cache for 4 hours (this data changes infrequently)
+ await cacheUtils.set(cacheKey, subjects, 14400);
+
+ res.json({
+ success: true,
+ data: subjects,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get subject datatypes
+ */
+ async getSubjectDatatypes(req, res, next) {
+ try {
+ const datatypes = Subject.rawAttributes;
+ res.json({
+ success: true,
+ data: datatypes,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get chapters by subject ID
+ */
+ async getChaptersBySubject(req, res, next) {
+ try {
+ const { id } = req.params;
+ const { page = 1, limit = 50, is_published } = req.query;
+ const offset = (page - 1) * limit;
+
+ // Generate cache key
+ const cacheKey = `subject:${id}:chapters:${page}:${limit}:${is_published || 'all'}`;
+
+ // Try cache first
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ // Check if subject exists
+ const subject = await Subject.findByPk(id);
+ if (!subject) {
+ return res.status(404).json({
+ success: false,
+ message: 'Subject not found',
+ });
+ }
+
+ // Build query conditions
+ const where = { subject_id: id };
+ if (is_published !== undefined) where.is_published = is_published === 'true';
+
+ // Query chapters
+ const { count, rows } = await Chapter.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ order: [['display_order', 'ASC'], ['chapter_number', 'ASC']],
+ });
+
+ const result = {
+ subject: {
+ id: subject.id,
+ subject_code: subject.subject_code,
+ subject_name: subject.subject_name,
+ },
+ chapters: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ // Cache the result
+ await cacheUtils.set(cacheKey, result, 3600); // Cache for 1 hour
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new SubjectController();
diff --git a/controllers/subscriptionController.js b/controllers/subscriptionController.js
new file mode 100644
index 0000000..c285341
--- /dev/null
+++ b/controllers/subscriptionController.js
@@ -0,0 +1,253 @@
+const { SubscriptionPlan, UserSubscription, UsersAuth } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob } = require('../config/bullmq');
+const { v4: uuidv4 } = require('uuid');
+
+/**
+ * Subscription Controller - Quản lý gói thẻ tháng
+ */
+class SubscriptionController {
+ /**
+ * GET /api/subscriptions/plans - Danh sách các gói subscription
+ */
+ async getPlans(req, res, next) {
+ try {
+ const { target_role } = req.query;
+ const cacheKey = `subscription:plans:${target_role || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = { is_active: true };
+ if (target_role) where.target_role = target_role;
+
+ const plans = await SubscriptionPlan.findAll({
+ where,
+ order: [['price', 'ASC']],
+ });
+
+ await cacheUtils.set(cacheKey, plans, 1800); // Cache 30 min
+
+ res.json({
+ success: true,
+ data: plans,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * POST /api/subscriptions/purchase - Mua gói subscription
+ */
+ async purchaseSubscription(req, res, next) {
+ try {
+ const { user_id, plan_id, payment_method, transaction_id } = req.body;
+
+ if (!user_id || !plan_id || !payment_method || !transaction_id) {
+ return res.status(400).json({
+ success: false,
+ message: 'Missing required fields: user_id, plan_id, payment_method, transaction_id',
+ });
+ }
+
+ // Kiểm tra plan có tồn tại không
+ const plan = await SubscriptionPlan.findByPk(plan_id);
+ if (!plan || !plan.is_active) {
+ return res.status(404).json({
+ success: false,
+ message: 'Subscription plan not found or inactive',
+ });
+ }
+
+ // Kiểm tra user có subscription đang active không
+ const existingSubscription = await UserSubscription.findOne({
+ where: {
+ user_id,
+ status: 'active',
+ },
+ });
+
+ if (existingSubscription) {
+ return res.status(400).json({
+ success: false,
+ message: 'User already has an active subscription',
+ });
+ }
+
+ const start_date = new Date();
+ const end_date = new Date(start_date);
+ end_date.setDate(end_date.getDate() + plan.duration_days);
+
+ const subscriptionData = {
+ id: uuidv4(),
+ user_id,
+ plan_id,
+ start_date,
+ end_date,
+ status: 'active',
+ transaction_id,
+ payment_method,
+ payment_amount: plan.price,
+ auto_renew: req.body.auto_renew || false,
+ };
+
+ // Async write to DB via BullMQ
+ await addDatabaseWriteJob('create', 'UserSubscription', subscriptionData);
+
+ // Clear cache
+ await cacheUtils.del(`subscription:user:${user_id}`);
+
+ res.status(202).json({
+ success: true,
+ message: 'Subscription purchase request queued',
+ data: subscriptionData,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * GET /api/subscriptions/user/:user_id - Kiểm tra subscription status của user
+ */
+ async getUserSubscription(req, res, next) {
+ try {
+ const { user_id } = req.params;
+ const cacheKey = `subscription:user:${user_id}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const subscription = await UserSubscription.findOne({
+ where: { user_id },
+ include: [
+ {
+ model: SubscriptionPlan,
+ as: 'plan',
+ attributes: ['name', 'price', 'features', 'duration_days'],
+ },
+ ],
+ order: [['created_at', 'DESC']],
+ });
+
+ if (!subscription) {
+ return res.status(404).json({
+ success: false,
+ message: 'No subscription found for this user',
+ });
+ }
+
+ // Check nếu subscription đã hết hạn
+ const now = new Date();
+ if (subscription.status === 'active' && new Date(subscription.end_date) < now) {
+ subscription.status = 'expired';
+ await addDatabaseWriteJob('update', 'UserSubscription', {
+ id: subscription.id,
+ status: 'expired',
+ });
+ }
+
+ await cacheUtils.set(cacheKey, subscription, 900); // Cache 15 min
+
+ res.json({
+ success: true,
+ data: subscription,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * POST /api/subscriptions/:id/cancel - Hủy subscription
+ */
+ async cancelSubscription(req, res, next) {
+ try {
+ const { id } = req.params;
+ const { reason } = req.body;
+
+ const subscription = await UserSubscription.findByPk(id);
+
+ if (!subscription) {
+ return res.status(404).json({
+ success: false,
+ message: 'Subscription not found',
+ });
+ }
+
+ if (subscription.status === 'cancelled') {
+ return res.status(400).json({
+ success: false,
+ message: 'Subscription already cancelled',
+ });
+ }
+
+ // Async update via BullMQ
+ await addDatabaseWriteJob('update', 'UserSubscription', {
+ id,
+ status: 'cancelled',
+ auto_renew: false,
+ });
+
+ // Clear cache
+ await cacheUtils.del(`subscription:user:${subscription.user_id}`);
+
+ res.status(202).json({
+ success: true,
+ message: 'Subscription cancellation request queued',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * GET /api/subscriptions/stats - Thống kê subscription
+ */
+ async getSubscriptionStats(req, res, next) {
+ try {
+ const cacheKey = 'subscription:stats';
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const stats = await UserSubscription.findAll({
+ attributes: [
+ 'status',
+ [require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
+ ],
+ group: ['status'],
+ });
+
+ await cacheUtils.set(cacheKey, stats, 300); // Cache 5 min
+
+ res.json({
+ success: true,
+ data: stats,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new SubscriptionController();
diff --git a/controllers/teacherController.js b/controllers/teacherController.js
new file mode 100644
index 0000000..4d81a83
--- /dev/null
+++ b/controllers/teacherController.js
@@ -0,0 +1,274 @@
+const { TeacherDetail, UserProfile, UsersAuth } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob } = require('../config/bullmq');
+const teacherProfileService = require('../services/teacherProfileService');
+
+/**
+ * Teacher Controller - Quản lý giáo viên
+ */
+class TeacherController {
+ /**
+ * Get all teachers with pagination and caching
+ */
+ async getAllTeachers(req, res, next) {
+ try {
+ const { page = 1, limit = 20, status, teacher_type, school_id } = req.query;
+ const offset = (page - 1) * limit;
+
+ const cacheKey = `teachers:list:${page}:${limit}:${status || 'all'}:${teacher_type || 'all'}:${school_id || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = {};
+ if (status) where.status = status;
+ if (teacher_type) where.teacher_type = teacher_type;
+
+ // Query teachers
+ const { count, rows } = await TeacherDetail.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ order: [['created_at', 'DESC']],
+ });
+
+ // Manually fetch profiles for each teacher
+ const teachersWithProfiles = await Promise.all(
+ rows.map(async (teacher) => {
+ const profile = await UserProfile.findOne({
+ where: { user_id: teacher.user_id },
+ attributes: ['id', 'full_name', 'phone', 'date_of_birth', 'gender', 'address', 'school_id', 'city', 'district', 'avatar_url'],
+ });
+
+ // Filter by school_id if provided
+ if (school_id && (!profile || profile.school_id !== school_id)) {
+ return null;
+ }
+
+ return {
+ ...teacher.toJSON(),
+ profile: profile ? profile.toJSON() : null,
+ };
+ })
+ );
+
+ // Filter out nulls (teachers not matching school_id)
+ const filteredTeachers = teachersWithProfiles.filter(t => t !== null);
+
+ const result = {
+ teachers: filteredTeachers,
+ pagination: {
+ total: school_id ? filteredTeachers.length : count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil((school_id ? filteredTeachers.length : count) / limit),
+ },
+ };
+
+ await cacheUtils.set(cacheKey, result, 1800);
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get teacher by ID with manual profile fetch
+ */
+ async getTeacherById(req, res, next) {
+ try {
+ const { id } = req.params;
+ const cacheKey = `teacher:${id}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const teacher = await TeacherDetail.findByPk(id);
+
+ if (!teacher) {
+ return res.status(404).json({
+ success: false,
+ message: 'Teacher not found',
+ });
+ }
+
+ // Manually fetch profile
+ const profile = await UserProfile.findOne({
+ where: { user_id: teacher.user_id },
+ });
+
+ const teacherData = {
+ ...teacher.toJSON(),
+ profile: profile ? profile.toJSON() : null,
+ };
+
+ await cacheUtils.set(cacheKey, teacherData, 3600);
+
+ res.json({
+ success: true,
+ data: teacherData,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Create new teacher with full profile (users_auth + user_profile + teacher_detail)
+ */
+ async createTeacher(req, res, next) {
+ try {
+ const teacherData = req.body;
+
+ // Use service to create full teacher profile
+ const result = await teacherProfileService.createTeacherWithProfile(teacherData);
+
+ // Clear cache
+ await cacheUtils.deletePattern('teachers:list:*');
+
+ res.status(201).json({
+ success: true,
+ message: 'Teacher created successfully',
+ data: {
+ user_id: result.user_id,
+ username: result.username,
+ email: result.email,
+ temporary_password: result.temporary_password, // null if user provided password
+ teacher_code: result.teacher_detail.teacher_code,
+ teacher_type: result.teacher_detail.teacher_type,
+ profile: {
+ full_name: result.profile.full_name,
+ needs_update: result.profile.etc?.needs_profile_update || false,
+ }
+ },
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Update teacher profile (cho phép user tự cập nhật)
+ */
+ async updateTeacherProfile(req, res, next) {
+ try {
+ const { id } = req.params; // user_id or teacher_id
+ const profileData = req.body;
+
+ // Nếu id là teacher_id, tìm user_id
+ let userId = id;
+ if (profileData.teacher_id) {
+ const teacher = await TeacherDetail.findByPk(profileData.teacher_id);
+ if (!teacher) {
+ return res.status(404).json({
+ success: false,
+ message: 'Teacher not found',
+ });
+ }
+ userId = teacher.user_id;
+ }
+
+ // Update profile
+ const updatedProfile = await teacherProfileService.updateTeacherProfile(userId, profileData);
+
+ // Clear cache
+ await cacheUtils.delete(`teacher:${id}`);
+ await cacheUtils.delete(`user:profile:${userId}`);
+ await cacheUtils.deletePattern('teachers:list:*');
+
+ res.json({
+ success: true,
+ message: 'Teacher profile updated successfully',
+ data: updatedProfile,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Update teacher (async via BullMQ)
+ */
+ async updateTeacher(req, res, next) {
+ try {
+ const { id } = req.params;
+ const updates = req.body;
+
+ const job = await addDatabaseWriteJob('update', 'TeacherDetail', {
+ id,
+ updates,
+ });
+
+ await cacheUtils.delete(`teacher:${id}`);
+ await cacheUtils.deletePattern('teachers:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Teacher update job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Delete teacher (update status to resigned)
+ */
+ async deleteTeacher(req, res, next) {
+ try {
+ const { id } = req.params;
+
+ const job = await addDatabaseWriteJob('update', 'TeacherDetail', {
+ id,
+ updates: { status: 'resigned' },
+ });
+
+ await cacheUtils.delete(`teacher:${id}`);
+ await cacheUtils.deletePattern('teachers:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'Teacher deletion job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get teacher datatypes
+ */
+ async getTeacherDatatypes(req, res, next) {
+ try {
+ const datatypes = TeacherDetail.rawAttributes;
+ res.json({
+ success: true,
+ data: datatypes,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new TeacherController();
diff --git a/controllers/trainingController.js b/controllers/trainingController.js
new file mode 100644
index 0000000..083467c
--- /dev/null
+++ b/controllers/trainingController.js
@@ -0,0 +1,318 @@
+const { StaffTrainingAssignment, StaffAchievement, UsersAuth, Subject } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob } = require('../config/bullmq');
+const { v4: uuidv4 } = require('uuid');
+
+/**
+ * Training Controller - Quản lý đào tạo nhân sự
+ */
+class TrainingController {
+ /**
+ * GET /api/training/assignments/:staff_id - Danh sách khóa học bắt buộc của nhân viên
+ */
+ async getStaffAssignments(req, res, next) {
+ try {
+ const { staff_id } = req.params;
+ const { status, priority } = req.query;
+
+ const cacheKey = `training:assignments:${staff_id}:${status || 'all'}:${priority || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = { staff_id };
+ if (status) where.status = status;
+ if (priority) where.priority = priority;
+
+ const assignments = await StaffTrainingAssignment.findAll({
+ where,
+ include: [
+ {
+ model: Subject,
+ as: 'subject',
+ attributes: ['subject_name', 'subject_code', 'description'],
+ },
+ {
+ model: UsersAuth,
+ as: 'assignedBy',
+ attributes: ['username', 'full_name'],
+ },
+ ],
+ order: [
+ ['priority', 'DESC'],
+ ['deadline', 'ASC'],
+ ],
+ });
+
+ // Check for overdue assignments
+ const now = new Date();
+ for (const assignment of assignments) {
+ if (assignment.status !== 'completed' && new Date(assignment.deadline) < now) {
+ if (assignment.status !== 'overdue') {
+ await addDatabaseWriteJob('update', 'StaffTrainingAssignment', {
+ id: assignment.id,
+ status: 'overdue',
+ });
+ assignment.status = 'overdue';
+ }
+ }
+ }
+
+ await cacheUtils.set(cacheKey, assignments, 900); // Cache 15 min
+
+ res.json({
+ success: true,
+ data: assignments,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * POST /api/training/assignments - Phân công đào tạo mới
+ */
+ async assignTraining(req, res, next) {
+ try {
+ const { staff_id, course_name, subject_id, deadline, priority, notes, assigned_by } = req.body;
+
+ if (!staff_id || !course_name || !deadline || !assigned_by) {
+ return res.status(400).json({
+ success: false,
+ message: 'Missing required fields: staff_id, course_name, deadline, assigned_by',
+ });
+ }
+
+ const assignmentData = {
+ id: uuidv4(),
+ staff_id,
+ course_name,
+ subject_id: subject_id || null,
+ assigned_by,
+ assigned_date: new Date(),
+ deadline: new Date(deadline),
+ status: 'pending',
+ priority: priority || 'normal',
+ notes: notes || null,
+ };
+
+ // Async write to DB via BullMQ
+ await addDatabaseWriteJob('create', 'StaffTrainingAssignment', assignmentData);
+
+ // Clear cache
+ await cacheUtils.del(`training:assignments:${staff_id}:*`);
+
+ res.status(202).json({
+ success: true,
+ message: 'Training assignment request queued',
+ data: assignmentData,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * PUT /api/training/assignments/:id/status - Cập nhật trạng thái assignment
+ */
+ async updateAssignmentStatus(req, res, next) {
+ try {
+ const { id } = req.params;
+ const { status } = req.body;
+
+ const validStatuses = ['pending', 'in_progress', 'completed', 'overdue'];
+ if (!validStatuses.includes(status)) {
+ return res.status(400).json({
+ success: false,
+ message: `Invalid status. Must be one of: ${validStatuses.join(', ')}`,
+ });
+ }
+
+ const assignment = await StaffTrainingAssignment.findByPk(id);
+ if (!assignment) {
+ return res.status(404).json({
+ success: false,
+ message: 'Assignment not found',
+ });
+ }
+
+ await addDatabaseWriteJob('update', 'StaffTrainingAssignment', {
+ id,
+ status,
+ });
+
+ // Clear cache
+ await cacheUtils.del(`training:assignments:${assignment.staff_id}:*`);
+
+ res.status(202).json({
+ success: true,
+ message: 'Assignment status update queued',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * POST /api/training/achievements - Ghi nhận hoàn thành khóa đào tạo
+ */
+ async createAchievement(req, res, next) {
+ try {
+ const {
+ staff_id,
+ course_name,
+ course_id,
+ completion_date,
+ certificate_url,
+ certificate_code,
+ type,
+ score,
+ total_hours,
+ verified_by,
+ } = req.body;
+
+ if (!staff_id || !course_name || !completion_date) {
+ return res.status(400).json({
+ success: false,
+ message: 'Missing required fields: staff_id, course_name, completion_date',
+ });
+ }
+
+ const achievementData = {
+ id: uuidv4(),
+ staff_id,
+ course_name,
+ course_id: course_id || null,
+ completion_date: new Date(completion_date),
+ certificate_url: certificate_url || null,
+ certificate_code: certificate_code || null,
+ type: type || 'mandatory',
+ score: score || null,
+ total_hours: total_hours || null,
+ verified_by: verified_by || null,
+ verified_at: verified_by ? new Date() : null,
+ };
+
+ // Async write to DB via BullMQ
+ await addDatabaseWriteJob('create', 'StaffAchievement', achievementData);
+
+ // Clear cache
+ await cacheUtils.del(`training:achievements:${staff_id}`);
+
+ res.status(202).json({
+ success: true,
+ message: 'Achievement creation request queued',
+ data: achievementData,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * GET /api/training/achievements/:staff_id - Danh sách chứng chỉ của nhân viên
+ */
+ async getStaffAchievements(req, res, next) {
+ try {
+ const { staff_id } = req.params;
+ const { type } = req.query;
+
+ const cacheKey = `training:achievements:${staff_id}:${type || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = { staff_id };
+ if (type) where.type = type;
+
+ const achievements = await StaffAchievement.findAll({
+ where,
+ include: [
+ {
+ model: Subject,
+ as: 'course',
+ attributes: ['subject_name', 'subject_code'],
+ },
+ {
+ model: UsersAuth,
+ as: 'verifiedBy',
+ attributes: ['username', 'full_name'],
+ },
+ ],
+ order: [['completion_date', 'DESC']],
+ });
+
+ await cacheUtils.set(cacheKey, achievements, 1800); // Cache 30 min
+
+ res.json({
+ success: true,
+ data: achievements,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * GET /api/training/stats - Thống kê đào tạo
+ */
+ async getTrainingStats(req, res, next) {
+ try {
+ const cacheKey = 'training:stats';
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const assignmentStats = await StaffTrainingAssignment.findAll({
+ attributes: [
+ 'status',
+ [require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
+ ],
+ group: ['status'],
+ });
+
+ const achievementStats = await StaffAchievement.findAll({
+ attributes: [
+ 'type',
+ [require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
+ [require('sequelize').fn('SUM', require('sequelize').col('total_hours')), 'total_hours'],
+ ],
+ group: ['type'],
+ });
+
+ const stats = {
+ assignments: assignmentStats,
+ achievements: achievementStats,
+ };
+
+ await cacheUtils.set(cacheKey, stats, 300); // Cache 5 min
+
+ res.json({
+ success: true,
+ data: stats,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new TrainingController();
diff --git a/controllers/userController.js b/controllers/userController.js
new file mode 100644
index 0000000..4d366f6
--- /dev/null
+++ b/controllers/userController.js
@@ -0,0 +1,495 @@
+const { UsersAuth, UserProfile } = require('../models');
+const { cacheUtils } = require('../config/redis');
+const { addDatabaseWriteJob } = require('../config/bullmq');
+const bcrypt = require('bcrypt');
+const jwt = require('jsonwebtoken');
+const crypto = require('crypto');
+
+// JWT Secret - nên lưu trong environment variable
+const JWT_SECRET = process.env.JWT_SECRET || 'sena-secret-key-2026';
+const JWT_EXPIRES_IN = '24h';
+
+/**
+ * User Controller - Quản lý người dùng (Auth + Profile)
+ */
+class UserController {
+ /**
+ * Login - Xác thực người dùng
+ */
+ async login(req, res, next) {
+ try {
+ const { username, password } = req.body;
+
+ // Validate input
+ if (!username || !password) {
+ return res.status(400).json({
+ success: false,
+ message: 'Username và password là bắt buộc',
+ });
+ }
+
+ // Tìm user theo username hoặc email
+ const user = await UsersAuth.findOne({
+ where: {
+ [require('sequelize').Op.or]: [
+ { username: username },
+ { email: username },
+ ],
+ },
+ include: [{
+ model: UserProfile,
+ as: 'profile',
+ }],
+ });
+
+ if (!user) {
+ return res.status(401).json({
+ success: false,
+ message: 'Username hoặc password không đúng',
+ });
+ }
+
+ // Kiểm tra tài khoản có bị khóa không
+ if (user.is_locked) {
+ if (user.locked_until && new Date() < new Date(user.locked_until)) {
+ return res.status(403).json({
+ success: false,
+ message: 'Tài khoản bị khóa đến ' + user.locked_until,
+ });
+ } else {
+ // Mở khóa nếu hết thời gian khóa
+ await user.update({
+ is_locked: false,
+ locked_until: null,
+ failed_login_attempts: 0
+ });
+ }
+ }
+
+ // Kiểm tra tài khoản có active không
+ if (!user.is_active) {
+ return res.status(403).json({
+ success: false,
+ message: 'Tài khoản đã bị vô hiệu hóa',
+ });
+ }
+
+ // Verify password
+ const passwordMatch = await bcrypt.compare(password + user.salt, user.password_hash);
+
+ if (!passwordMatch) {
+ // Tăng số lần đăng nhập thất bại
+ const failedAttempts = user.failed_login_attempts + 1;
+ const updates = { failed_login_attempts: failedAttempts };
+
+ // Khóa tài khoản sau 5 lần thất bại
+ if (failedAttempts >= 5) {
+ updates.is_locked = true;
+ updates.locked_until = new Date(Date.now() + 30 * 60 * 1000); // Khóa 30 phút
+ }
+
+ await user.update(updates);
+
+ return res.status(401).json({
+ success: false,
+ message: 'Username hoặc password không đúng',
+ attemptsLeft: Math.max(0, 5 - failedAttempts),
+ });
+ }
+
+ // Đăng nhập thành công - Reset failed attempts
+ const sessionId = crypto.randomUUID();
+ const clientIp = req.ip || req.connection.remoteAddress;
+
+ await user.update({
+ failed_login_attempts: 0,
+ login_count: user.login_count + 1,
+ last_login: new Date(),
+ last_login_ip: clientIp,
+ current_session_id: sessionId,
+ });
+
+ // Tạo JWT token
+ const token = jwt.sign(
+ {
+ userId: user.id,
+ username: user.username,
+ email: user.email,
+ sessionId: sessionId,
+ },
+ JWT_SECRET,
+ { expiresIn: JWT_EXPIRES_IN }
+ );
+
+ // Return success response
+ res.json({
+ success: true,
+ message: 'Đăng nhập thành công',
+ data: {
+ token,
+ user: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ profile: user.profile,
+ last_login: user.last_login,
+ login_count: user.login_count,
+ },
+ },
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Register - Tạo tài khoản mới
+ */
+ async register(req, res, next) {
+ try {
+ const { username, email, password, full_name, phone, school_id } = req.body;
+
+ // Validate input
+ if (!username || !email || !password) {
+ return res.status(400).json({
+ success: false,
+ message: 'Username, email và password là bắt buộc',
+ });
+ }
+
+ // Kiểm tra username đã tồn tại
+ const existingUser = await UsersAuth.findOne({
+ where: {
+ [require('sequelize').Op.or]: [
+ { username },
+ { email },
+ ],
+ },
+ });
+
+ if (existingUser) {
+ return res.status(409).json({
+ success: false,
+ message: 'Username hoặc email đã tồn tại',
+ });
+ }
+
+ // Hash password
+ const salt = crypto.randomBytes(16).toString('hex');
+ const passwordHash = await bcrypt.hash(password + salt, 10);
+
+ // Tạo user mới
+ const newUser = await UsersAuth.create({
+ username,
+ email,
+ password_hash: passwordHash,
+ salt,
+ qr_secret: crypto.randomBytes(32).toString('hex'),
+ });
+
+ // Tạo profile nếu có thông tin
+ if (full_name || phone || school_id) {
+ await UserProfile.create({
+ user_id: newUser.id,
+ full_name: full_name || username,
+ phone,
+ school_id,
+ });
+ }
+
+ res.status(201).json({
+ success: true,
+ message: 'Đăng ký tài khoản thành công',
+ data: {
+ id: newUser.id,
+ username: newUser.username,
+ email: newUser.email,
+ },
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Verify Token - Xác thực JWT token
+ */
+ async verifyToken(req, res, next) {
+ try {
+ const token = req.headers.authorization?.replace('Bearer ', '');
+
+ if (!token) {
+ return res.status(401).json({
+ success: false,
+ message: 'Token không được cung cấp',
+ });
+ }
+
+ const decoded = jwt.verify(token, JWT_SECRET);
+
+ // Kiểm tra user còn tồn tại và session hợp lệ
+ const user = await UsersAuth.findByPk(decoded.userId, {
+ include: [{
+ model: UserProfile,
+ as: 'profile',
+ }],
+ });
+
+ if (!user || !user.is_active) {
+ return res.status(401).json({
+ success: false,
+ message: 'Token không hợp lệ hoặc tài khoản đã bị vô hiệu hóa',
+ });
+ }
+
+ if (user.current_session_id !== decoded.sessionId) {
+ return res.status(401).json({
+ success: false,
+ message: 'Phiên đăng nhập đã hết hạn',
+ });
+ }
+
+ res.json({
+ success: true,
+ message: 'Token hợp lệ',
+ data: {
+ user: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ profile: user.profile,
+ },
+ },
+ });
+ } catch (error) {
+ if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
+ return res.status(401).json({
+ success: false,
+ message: 'Token không hợp lệ hoặc đã hết hạn',
+ });
+ }
+ next(error);
+ }
+ }
+
+ /**
+ * Logout - Đăng xuất
+ */
+ async logout(req, res, next) {
+ try {
+ const token = req.headers.authorization?.replace('Bearer ', '');
+
+ if (!token) {
+ return res.status(401).json({
+ success: false,
+ message: 'Token không được cung cấp',
+ });
+ }
+
+ const decoded = jwt.verify(token, JWT_SECRET);
+
+ // Xóa session hiện tại
+ await UsersAuth.update(
+ { current_session_id: null },
+ { where: { id: decoded.userId } }
+ );
+
+ res.json({
+ success: true,
+ message: 'Đăng xuất thành công',
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get all users with pagination and caching
+ */
+ async getAllUsers(req, res, next) {
+ try {
+ const { page = 1, limit = 20, is_active, school_id } = req.query;
+ const offset = (page - 1) * limit;
+
+ const cacheKey = `users:list:${page}:${limit}:${is_active || 'all'}:${school_id || 'all'}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const where = {};
+ if (is_active !== undefined) where.is_active = is_active === 'true';
+
+ const { count, rows } = await UsersAuth.findAndCountAll({
+ where,
+ limit: parseInt(limit),
+ offset: parseInt(offset),
+ include: [{
+ model: UserProfile,
+ as: 'profile',
+ attributes: ['full_name', 'phone', 'school_id'],
+ where: school_id ? { school_id } : undefined,
+ }],
+ order: [['created_at', 'DESC']],
+ });
+
+ const result = {
+ users: rows,
+ pagination: {
+ total: count,
+ page: parseInt(page),
+ limit: parseInt(limit),
+ totalPages: Math.ceil(count / limit),
+ },
+ };
+
+ await cacheUtils.set(cacheKey, result, 1800);
+
+ res.json({
+ success: true,
+ data: result,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get user by ID with profile
+ */
+ async getUserById(req, res, next) {
+ try {
+ const { id } = req.params;
+ const cacheKey = `user:${id}`;
+
+ const cached = await cacheUtils.get(cacheKey);
+ if (cached) {
+ return res.json({
+ success: true,
+ data: cached,
+ cached: true,
+ });
+ }
+
+ const user = await UsersAuth.findByPk(id, {
+ include: [{
+ model: UserProfile,
+ as: 'profile',
+ }],
+ });
+
+ if (!user) {
+ return res.status(404).json({
+ success: false,
+ message: 'User not found',
+ });
+ }
+
+ await cacheUtils.set(cacheKey, user, 3600);
+
+ res.json({
+ success: true,
+ data: user,
+ cached: false,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Create new user (async via BullMQ)
+ */
+ async createUser(req, res, next) {
+ try {
+ const userData = req.body;
+
+ const job = await addDatabaseWriteJob('create', 'UsersAuth', userData);
+
+ await cacheUtils.deletePattern('users:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'User creation job queued',
+ jobId: job.id,
+ data: userData,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Update user (async via BullMQ)
+ */
+ async updateUser(req, res, next) {
+ try {
+ const { id } = req.params;
+ const updates = req.body;
+
+ const job = await addDatabaseWriteJob('update', 'UsersAuth', {
+ id,
+ updates,
+ });
+
+ await cacheUtils.delete(`user:${id}`);
+ await cacheUtils.deletePattern('users:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'User update job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Delete user (soft delete via is_active)
+ */
+ async deleteUser(req, res, next) {
+ try {
+ const { id } = req.params;
+
+ const job = await addDatabaseWriteJob('update', 'UsersAuth', {
+ id,
+ updates: { is_active: false },
+ });
+
+ await cacheUtils.delete(`user:${id}`);
+ await cacheUtils.deletePattern('users:list:*');
+
+ res.status(202).json({
+ success: true,
+ message: 'User deletion job queued',
+ jobId: job.id,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+
+ /**
+ * Get user datatypes
+ */
+ async getUserDatatypes(req, res, next) {
+ try {
+ const datatypes = UsersAuth.rawAttributes;
+ res.json({
+ success: true,
+ data: datatypes,
+ });
+ } catch (error) {
+ next(error);
+ }
+ }
+}
+
+module.exports = new UserController();
diff --git a/middleware/errorHandler.js b/middleware/errorHandler.js
new file mode 100644
index 0000000..19f58e5
--- /dev/null
+++ b/middleware/errorHandler.js
@@ -0,0 +1,144 @@
+/**
+ * Error Handler Middleware
+ * Xử lý tất cả errors trong ứng dụng
+ */
+
+class AppError extends Error {
+ constructor(message, statusCode = 500, isOperational = true) {
+ super(message);
+ this.statusCode = statusCode;
+ this.isOperational = isOperational;
+ this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
+
+ Error.captureStackTrace(this, this.constructor);
+ }
+}
+
+/**
+ * Handle Sequelize Validation Errors
+ */
+const handleSequelizeValidationError = (err) => {
+ const errors = err.errors.map(e => ({
+ field: e.path,
+ message: e.message,
+ }));
+
+ return new AppError(
+ 'Validation Error',
+ 400,
+ true,
+ errors
+ );
+};
+
+/**
+ * Handle Sequelize Unique Constraint Error
+ */
+const handleSequelizeUniqueError = (err) => {
+ const field = err.errors[0]?.path || 'field';
+ const message = `${field} already exists`;
+
+ return new AppError(message, 409);
+};
+
+/**
+ * Handle Sequelize Foreign Key Error
+ */
+const handleSequelizeForeignKeyError = (err) => {
+ return new AppError('Referenced record not found', 404);
+};
+
+/**
+ * Development Error Response
+ */
+const sendErrorDev = (err, res) => {
+ res.status(err.statusCode).json({
+ success: false,
+ status: err.status,
+ error: err,
+ message: err.message,
+ stack: err.stack,
+ });
+};
+
+/**
+ * Production Error Response
+ */
+const sendErrorProd = (err, res) => {
+ // Operational, trusted error: send message to client
+ if (err.isOperational) {
+ res.status(err.statusCode).json({
+ success: false,
+ status: err.status,
+ message: err.message,
+ errors: err.errors,
+ });
+ }
+ // Programming or unknown error: don't leak error details
+ else {
+ console.error('❌ ERROR:', err);
+
+ res.status(500).json({
+ success: false,
+ status: 'error',
+ message: 'Something went wrong!',
+ });
+ }
+};
+
+/**
+ * Global Error Handler Middleware
+ */
+const errorHandler = (err, req, res, next) => {
+ err.statusCode = err.statusCode || 500;
+ err.status = err.status || 'error';
+
+ if (process.env.NODE_ENV === 'development') {
+ sendErrorDev(err, res);
+ } else {
+ let error = { ...err };
+ error.message = err.message;
+
+ // Handle specific Sequelize errors
+ if (err.name === 'SequelizeValidationError') {
+ error = handleSequelizeValidationError(err);
+ }
+
+ if (err.name === 'SequelizeUniqueConstraintError') {
+ error = handleSequelizeUniqueError(err);
+ }
+
+ if (err.name === 'SequelizeForeignKeyConstraintError') {
+ error = handleSequelizeForeignKeyError(err);
+ }
+
+ sendErrorProd(error, res);
+ }
+};
+
+/**
+ * 404 Handler
+ */
+const notFoundHandler = (req, res, next) => {
+ const err = new AppError(
+ `Cannot find ${req.originalUrl} on this server`,
+ 404
+ );
+ next(err);
+};
+
+/**
+ * Async Error Wrapper
+ */
+const catchAsync = (fn) => {
+ return (req, res, next) => {
+ fn(req, res, next).catch(next);
+ };
+};
+
+module.exports = {
+ AppError,
+ errorHandler,
+ notFoundHandler,
+ catchAsync,
+};
diff --git a/models/AcademicYear.js b/models/AcademicYear.js
new file mode 100644
index 0000000..523ef59
--- /dev/null
+++ b/models/AcademicYear.js
@@ -0,0 +1,19 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const AcademicYear = sequelize.define('academic_years', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ year_name: { type: DataTypes.STRING(50), allowNull: false },
+ start_date: { type: DataTypes.DATE, allowNull: false },
+ end_date: { type: DataTypes.DATE, allowNull: false },
+ semester_count: { type: DataTypes.INTEGER, defaultValue: 2 },
+ is_current: { type: DataTypes.BOOLEAN, defaultValue: false },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'academic_years',
+ timestamps: true,
+ underscored: true,
+});
+
+module.exports = AcademicYear;
diff --git a/models/AttendanceDaily.js b/models/AttendanceDaily.js
new file mode 100644
index 0000000..7b93e5b
--- /dev/null
+++ b/models/AttendanceDaily.js
@@ -0,0 +1,22 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const AttendanceDaily = sequelize.define('attendance_daily', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ user_id: { type: DataTypes.UUID, allowNull: false },
+ school_id: { type: DataTypes.UUID, allowNull: false },
+ class_id: { type: DataTypes.UUID },
+ attendance_date: { type: DataTypes.DATE, allowNull: false },
+ status: { type: DataTypes.ENUM('present', 'absent', 'late', 'excused'), allowNull: false },
+ check_in_time: { type: DataTypes.TIME },
+ check_out_time: { type: DataTypes.TIME },
+ notes: { type: DataTypes.TEXT },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'attendance_daily',
+ timestamps: true,
+ underscored: true,
+});
+
+module.exports = AttendanceDaily;
diff --git a/models/AttendanceLog.js b/models/AttendanceLog.js
new file mode 100644
index 0000000..e805d58
--- /dev/null
+++ b/models/AttendanceLog.js
@@ -0,0 +1,90 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * AttendanceLogs Model - Dữ liệu thô điểm danh từ QR
+ * Bảng này cần partition theo thời gian do data lớn
+ */
+const AttendanceLog = sequelize.define('attendance_logs', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ user_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'users_auth',
+ key: 'id',
+ },
+ comment: 'ID người dùng (học sinh/giáo viên)',
+ },
+ school_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'schools',
+ key: 'id',
+ },
+ comment: 'ID trường',
+ },
+ check_time: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ comment: 'Thời gian quét QR',
+ },
+ check_type: {
+ type: DataTypes.ENUM('IN', 'OUT'),
+ allowNull: false,
+ comment: 'Loại: Vào hoặc Ra',
+ },
+ device_id: {
+ type: DataTypes.STRING(100),
+ comment: 'ID thiết bị quét',
+ },
+ location: {
+ type: DataTypes.STRING(200),
+ comment: 'Vị trí quét (cổng, lớp học)',
+ },
+ qr_version: {
+ type: DataTypes.INTEGER,
+ comment: 'Version QR code',
+ },
+ ip_address: {
+ type: DataTypes.STRING(45),
+ comment: 'IP address thiết bị (IPv4/IPv6)',
+ },
+ user_agent: {
+ type: DataTypes.TEXT,
+ comment: 'User agent',
+ },
+ is_valid: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: true,
+ comment: 'QR code hợp lệ',
+ },
+ notes: {
+ type: DataTypes.TEXT,
+ comment: 'Ghi chú',
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+}, {
+ tableName: 'attendance_logs',
+ timestamps: false,
+ underscored: true,
+ indexes: [
+ { fields: ['user_id', 'check_time'] },
+ { fields: ['school_id', 'check_time'] },
+ { fields: ['check_time'] },
+ { fields: ['device_id'] },
+ { fields: ['is_valid'] },
+ ],
+ comment: 'Bảng log điểm danh thô - Cần partition theo tháng',
+});
+
+module.exports = AttendanceLog;
diff --git a/models/AuditLog.js b/models/AuditLog.js
new file mode 100644
index 0000000..5561ae3
--- /dev/null
+++ b/models/AuditLog.js
@@ -0,0 +1,35 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const AuditLog = sequelize.define('audit_logs', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ school_id: { type: DataTypes.UUID },
+ user_id: { type: DataTypes.UUID },
+ action: { type: DataTypes.STRING(100), allowNull: false },
+ entity_type: { type: DataTypes.STRING(100) },
+ entity_id: { type: DataTypes.UUID },
+ old_values: { type: DataTypes.JSON },
+ new_values: { type: DataTypes.JSON },
+ ip_address: { type: DataTypes.STRING(45) },
+ user_agent: { type: DataTypes.TEXT },
+ status: {
+ type: DataTypes.ENUM('success', 'failed'),
+ defaultValue: 'success'
+ },
+ error_message: { type: DataTypes.TEXT },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'audit_logs',
+ timestamps: false,
+ createdAt: 'created_at',
+ updatedAt: false,
+ indexes: [
+ { fields: ['school_id'] },
+ { fields: ['user_id'] },
+ { fields: ['action'] },
+ { fields: ['entity_type', 'entity_id'] },
+ { fields: ['created_at'] },
+ ],
+});
+
+module.exports = AuditLog;
diff --git a/models/Chapter.js b/models/Chapter.js
new file mode 100644
index 0000000..f14d87a
--- /dev/null
+++ b/models/Chapter.js
@@ -0,0 +1,69 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * Chapter Model - Chương trong giáo trình
+ * Một Subject có nhiều Chapter
+ */
+const Chapter = sequelize.define('chapters', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true
+ },
+ subject_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ comment: 'ID của giáo trình'
+ },
+ chapter_number: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ comment: 'Số thứ tự chương (1, 2, 3...)'
+ },
+ chapter_title: {
+ type: DataTypes.STRING(200),
+ allowNull: false,
+ comment: 'Tiêu đề chương'
+ },
+ chapter_description: {
+ type: DataTypes.TEXT,
+ comment: 'Mô tả về chương học'
+ },
+ duration_minutes: {
+ type: DataTypes.INTEGER,
+ comment: 'Thời lượng học (phút)'
+ },
+ is_published: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ comment: 'Đã xuất bản chưa'
+ },
+ display_order: {
+ type: DataTypes.INTEGER,
+ defaultValue: 0,
+ comment: 'Thứ tự hiển thị'
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW
+ },
+ updated_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW
+ },
+}, {
+ tableName: 'chapters',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['subject_id'] },
+ { fields: ['subject_id', 'chapter_number'], unique: true },
+ { fields: ['display_order'] },
+ { fields: ['is_published'] },
+ ],
+});
+
+module.exports = Chapter;
diff --git a/models/Class.js b/models/Class.js
new file mode 100644
index 0000000..65f9b7e
--- /dev/null
+++ b/models/Class.js
@@ -0,0 +1,22 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const Class = sequelize.define('classes', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ class_code: { type: DataTypes.STRING(20), unique: true, allowNull: false },
+ class_name: { type: DataTypes.STRING(100), allowNull: false },
+ school_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'schools', key: 'id' } },
+ academic_year_id: { type: DataTypes.UUID, allowNull: false },
+ grade_level: { type: DataTypes.INTEGER },
+ homeroom_teacher_id: { type: DataTypes.UUID },
+ max_students: { type: DataTypes.INTEGER, defaultValue: 30 },
+ current_students: { type: DataTypes.INTEGER, defaultValue: 0 },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'classes',
+ timestamps: true,
+ underscored: true,
+});
+
+module.exports = Class;
diff --git a/models/ClassSchedule.js b/models/ClassSchedule.js
new file mode 100644
index 0000000..73415a1
--- /dev/null
+++ b/models/ClassSchedule.js
@@ -0,0 +1,22 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const ClassSchedule = sequelize.define('class_schedules', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ class_id: { type: DataTypes.UUID, allowNull: false },
+ subject_id: { type: DataTypes.UUID, allowNull: false },
+ teacher_id: { type: DataTypes.UUID },
+ room_id: { type: DataTypes.UUID },
+ day_of_week: { type: DataTypes.INTEGER, comment: '1=Monday, 7=Sunday' },
+ period: { type: DataTypes.INTEGER, comment: 'Tiết học' },
+ start_time: { type: DataTypes.TIME },
+ end_time: { type: DataTypes.TIME },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'class_schedules',
+ timestamps: true,
+ underscored: true,
+});
+
+module.exports = ClassSchedule;
diff --git a/models/Game.js b/models/Game.js
new file mode 100644
index 0000000..ebc711e
--- /dev/null
+++ b/models/Game.js
@@ -0,0 +1,101 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * Game Model - Trò chơi giáo dục
+ * Các game engine/template để render nội dung học tập
+ * Một game có thể render nhiều lesson khác nhau có cùng type
+ */
+const Game = sequelize.define('games', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true
+ },
+ title: {
+ type: DataTypes.STRING(200),
+ allowNull: false,
+ comment: 'Tên trò chơi'
+ },
+ description: {
+ type: DataTypes.TEXT,
+ comment: 'Mô tả về trò chơi'
+ },
+ url: {
+ type: DataTypes.TEXT,
+ allowNull: false,
+ comment: 'URL của game (HTML5 game, Unity WebGL, etc.)'
+ },
+ thumbnail: {
+ type: DataTypes.TEXT,
+ comment: 'URL ảnh thumbnail'
+ },
+ type: {
+ type: DataTypes.STRING(50),
+ allowNull: false,
+ comment: 'Loại game: counting_quiz, math_game, word_puzzle, etc. - Must match lesson content_json.type'
+ },
+ config: {
+ type: DataTypes.JSON,
+ comment: 'Cấu hình game: controls, settings, features, etc.'
+ },
+ is_active: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: true,
+ comment: 'Game có sẵn sàng để sử dụng không'
+ },
+ is_premium: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ comment: 'Yêu cầu premium để chơi'
+ },
+ min_grade: {
+ type: DataTypes.INTEGER,
+ comment: 'Cấp lớp tối thiểu (1-12)'
+ },
+ max_grade: {
+ type: DataTypes.INTEGER,
+ comment: 'Cấp lớp tối đa (1-12)'
+ },
+ difficulty_level: {
+ type: DataTypes.ENUM('easy', 'medium', 'hard'),
+ comment: 'Độ khó'
+ },
+ play_count: {
+ type: DataTypes.INTEGER,
+ defaultValue: 0,
+ comment: 'Số lần đã chơi'
+ },
+ rating: {
+ type: DataTypes.DECIMAL(3, 2),
+ comment: 'Đánh giá (0.00 - 5.00)'
+ },
+ display_order: {
+ type: DataTypes.INTEGER,
+ defaultValue: 0,
+ comment: 'Thứ tự hiển thị'
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW
+ },
+ updated_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW
+ },
+}, {
+ tableName: 'games',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['type'] },
+ { fields: ['is_active'] },
+ { fields: ['is_premium'] },
+ { fields: ['display_order'] },
+ { fields: ['difficulty_level'] },
+ ],
+});
+
+module.exports = Game;
diff --git a/models/Grade.js b/models/Grade.js
new file mode 100644
index 0000000..292a8c2
--- /dev/null
+++ b/models/Grade.js
@@ -0,0 +1,100 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * Grades Model - Điểm số chi tiết của học sinh
+ * Bảng lớn nhất trong hệ thống
+ */
+const Grade = sequelize.define('grades', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ grade_item_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'grade_items',
+ key: 'id',
+ },
+ comment: 'ID cột điểm',
+ },
+ student_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ comment: 'ID học sinh',
+ },
+ class_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'classes',
+ key: 'id',
+ },
+ comment: 'ID lớp học',
+ },
+ score: {
+ type: DataTypes.DECIMAL(5, 2),
+ comment: 'Điểm số',
+ validate: {
+ min: 0,
+ max: 100,
+ },
+ },
+ grade_level: {
+ type: DataTypes.STRING(5),
+ comment: 'Xếp loại (A, B, C, D, F)',
+ },
+ comment: {
+ type: DataTypes.TEXT,
+ comment: 'Nhận xét của giáo viên',
+ },
+ graded_by: {
+ type: DataTypes.UUID,
+ comment: 'ID giáo viên chấm điểm',
+ },
+ graded_at: {
+ type: DataTypes.DATE,
+ comment: 'Thời gian chấm điểm',
+ },
+ is_published: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ comment: 'Đã công bố cho học sinh/phụ huynh',
+ },
+ published_at: {
+ type: DataTypes.DATE,
+ comment: 'Thời gian công bố',
+ },
+ is_locked: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ comment: 'Khóa điểm, không cho sửa',
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+ updated_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+}, {
+ tableName: 'grades',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['grade_item_id', 'student_id'], unique: true },
+ { fields: ['student_id', 'class_id'] },
+ { fields: ['class_id'] },
+ { fields: ['graded_by'] },
+ { fields: ['is_published'] },
+ { fields: ['created_at'] },
+ ],
+ comment: 'Bảng điểm chi tiết - Dữ liệu lớn nhất hệ thống',
+});
+
+module.exports = Grade;
diff --git a/models/GradeCategory.js b/models/GradeCategory.js
new file mode 100644
index 0000000..b645bb7
--- /dev/null
+++ b/models/GradeCategory.js
@@ -0,0 +1,19 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const GradeCategory = sequelize.define('grade_categories', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ category_code: { type: DataTypes.STRING(20), allowNull: false },
+ category_name: { type: DataTypes.STRING(100), allowNull: false },
+ subject_id: { type: DataTypes.UUID },
+ weight: { type: DataTypes.DECIMAL(5, 2), comment: 'Trọng số %' },
+ description: { type: DataTypes.TEXT },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'grade_categories',
+ timestamps: true,
+ underscored: true,
+});
+
+module.exports = GradeCategory;
diff --git a/models/GradeHistory.js b/models/GradeHistory.js
new file mode 100644
index 0000000..578b4b9
--- /dev/null
+++ b/models/GradeHistory.js
@@ -0,0 +1,21 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const GradeHistory = sequelize.define('grade_histories', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ grade_id: { type: DataTypes.UUID, allowNull: false },
+ student_id: { type: DataTypes.UUID, allowNull: false },
+ old_score: { type: DataTypes.DECIMAL(5, 2) },
+ new_score: { type: DataTypes.DECIMAL(5, 2) },
+ changed_by: { type: DataTypes.UUID },
+ change_reason: { type: DataTypes.TEXT },
+ changed_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'grade_histories',
+ timestamps: true,
+ underscored: true,
+});
+
+module.exports = GradeHistory;
diff --git a/models/GradeItem.js b/models/GradeItem.js
new file mode 100644
index 0000000..ca74f09
--- /dev/null
+++ b/models/GradeItem.js
@@ -0,0 +1,29 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const GradeItem = sequelize.define('grade_items', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ item_name: { type: DataTypes.STRING(200), allowNull: false },
+ category_id: { type: DataTypes.UUID, allowNull: false },
+ class_id: { type: DataTypes.UUID, allowNull: false },
+ max_score: { type: DataTypes.DECIMAL(5, 2), defaultValue: 100 },
+ exam_date: { type: DataTypes.DATE },
+ description: { type: DataTypes.TEXT },
+ is_premium: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Nội dung premium' },
+ is_training: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Nội dung đào tạo nhân sự' },
+ is_public: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Nội dung công khai' },
+ min_subscription_tier: { type: DataTypes.STRING(50), comment: 'Gói tối thiểu' },
+ min_interaction_time: { type: DataTypes.INTEGER, defaultValue: 0, comment: 'Thời gian tương tác tối thiểu (giây)' },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'grade_items',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['is_premium'] },
+ { fields: ['is_training'] },
+ ],
+});
+
+module.exports = GradeItem;
diff --git a/models/GradeSummary.js b/models/GradeSummary.js
new file mode 100644
index 0000000..6c77ec5
--- /dev/null
+++ b/models/GradeSummary.js
@@ -0,0 +1,20 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const GradeSummary = sequelize.define('grade_summaries', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ student_id: { type: DataTypes.UUID, allowNull: false },
+ subject_id: { type: DataTypes.UUID, allowNull: false },
+ academic_year_id: { type: DataTypes.UUID, allowNull: false },
+ semester: { type: DataTypes.INTEGER },
+ average_score: { type: DataTypes.DECIMAL(5, 2) },
+ final_grade: { type: DataTypes.STRING(5) },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'grade_summaries',
+ timestamps: true,
+ underscored: true,
+});
+
+module.exports = GradeSummary;
diff --git a/models/LeaveRequest.js b/models/LeaveRequest.js
new file mode 100644
index 0000000..d533557
--- /dev/null
+++ b/models/LeaveRequest.js
@@ -0,0 +1,22 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const LeaveRequest = sequelize.define('leave_requests', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ student_id: { type: DataTypes.UUID, allowNull: false },
+ requested_by: { type: DataTypes.UUID, allowNull: false, comment: 'Parent user_id' },
+ leave_from: { type: DataTypes.DATE, allowNull: false },
+ leave_to: { type: DataTypes.DATE, allowNull: false },
+ reason: { type: DataTypes.TEXT, allowNull: false },
+ status: { type: DataTypes.ENUM('pending', 'approved', 'rejected'), defaultValue: 'pending' },
+ approved_by: { type: DataTypes.UUID },
+ approved_at: { type: DataTypes.DATE },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'leave_requests',
+ timestamps: true,
+ underscored: true,
+});
+
+module.exports = LeaveRequest;
diff --git a/models/Lesson.js b/models/Lesson.js
new file mode 100644
index 0000000..8403838
--- /dev/null
+++ b/models/Lesson.js
@@ -0,0 +1,101 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * Lesson Model - Bài học trong chương
+ * Một Chapter có nhiều Lesson
+ */
+const Lesson = sequelize.define('lessons', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true
+ },
+ chapter_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ comment: 'ID của chương'
+ },
+ lesson_number: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ comment: 'Số thứ tự bài học (1, 2, 3...)'
+ },
+ lesson_title: {
+ type: DataTypes.STRING(200),
+ allowNull: false,
+ comment: 'Tiêu đề bài học'
+ },
+ lesson_type: {
+ type: DataTypes.ENUM('json_content', 'url_content'),
+ defaultValue: 'json_content',
+ comment: 'Loại bài học: json_content (nội dung JSON) hoặc url_content (URL)'
+ },
+ lesson_description: {
+ type: DataTypes.TEXT,
+ comment: 'Mô tả bài học'
+ },
+
+ // Dạng 1: JSON Content - Nội dung học tập dạng JSON
+ content_json: {
+ type: DataTypes.JSON,
+ comment: 'Nội dung học tập dạng JSON: text, quiz, interactive, assignment, etc.'
+ },
+
+ // Dạng 2: URL Content - Chứa link external
+ content_url: {
+ type: DataTypes.STRING(500),
+ comment: 'URL nội dung: video, audio, document, external link'
+ },
+ content_type: {
+ type: DataTypes.STRING(50),
+ comment: 'Loại content cho URL: video, audio, pdf, external_link, youtube, etc.'
+ },
+
+ duration_minutes: {
+ type: DataTypes.INTEGER,
+ comment: 'Thời lượng (phút)'
+ },
+ is_published: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ comment: 'Đã xuất bản'
+ },
+ is_free: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ comment: 'Cho phép học thử miễn phí'
+ },
+ display_order: {
+ type: DataTypes.INTEGER,
+ defaultValue: 0,
+ comment: 'Thứ tự hiển thị'
+ },
+ thumbnail_url: {
+ type: DataTypes.STRING(500),
+ comment: 'URL ảnh thumbnail'
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW
+ },
+ updated_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW
+ },
+}, {
+ tableName: 'lessons',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['chapter_id'] },
+ { fields: ['chapter_id', 'lesson_number'], unique: true },
+ { fields: ['display_order'] },
+ { fields: ['is_published'] },
+ { fields: ['lesson_type'] },
+ ],
+});
+
+module.exports = Lesson;
diff --git a/models/Message.js b/models/Message.js
new file mode 100644
index 0000000..c9d073a
--- /dev/null
+++ b/models/Message.js
@@ -0,0 +1,36 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const Message = sequelize.define('messages', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ school_id: { type: DataTypes.UUID },
+ sender_id: { type: DataTypes.UUID, allowNull: false },
+ receiver_id: { type: DataTypes.UUID },
+ receiver_role: { type: DataTypes.STRING(50) },
+ subject: { type: DataTypes.STRING(255) },
+ content: { type: DataTypes.TEXT, allowNull: false },
+ message_type: {
+ type: DataTypes.ENUM('direct', 'broadcast', 'class_message', 'parent_teacher'),
+ defaultValue: 'direct'
+ },
+ parent_message_id: { type: DataTypes.UUID },
+ attachments: { type: DataTypes.JSON },
+ is_read: { type: DataTypes.BOOLEAN, defaultValue: false },
+ read_at: { type: DataTypes.DATE },
+ is_archived: { type: DataTypes.BOOLEAN, defaultValue: false },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'messages',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['school_id'] },
+ { fields: ['sender_id'] },
+ { fields: ['receiver_id'] },
+ { fields: ['is_read'] },
+ { fields: ['parent_message_id'] },
+ ],
+});
+
+module.exports = Message;
diff --git a/models/Notification.js b/models/Notification.js
new file mode 100644
index 0000000..926a026
--- /dev/null
+++ b/models/Notification.js
@@ -0,0 +1,40 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const Notification = sequelize.define('notifications', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ school_id: { type: DataTypes.UUID },
+ title: { type: DataTypes.STRING(255), allowNull: false },
+ content: { type: DataTypes.TEXT },
+ notification_type: {
+ type: DataTypes.ENUM('announcement', 'event', 'emergency', 'reminder', 'grade_update', 'attendance_alert'),
+ defaultValue: 'announcement'
+ },
+ priority: {
+ type: DataTypes.ENUM('low', 'normal', 'high', 'urgent'),
+ defaultValue: 'normal'
+ },
+ target_role: { type: DataTypes.STRING(50) },
+ target_users: { type: DataTypes.JSON },
+ scheduled_at: { type: DataTypes.DATE },
+ sent_at: { type: DataTypes.DATE },
+ created_by: { type: DataTypes.UUID },
+ status: {
+ type: DataTypes.ENUM('draft', 'scheduled', 'sent', 'cancelled'),
+ defaultValue: 'draft'
+ },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'notifications',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['school_id'] },
+ { fields: ['notification_type'] },
+ { fields: ['status'] },
+ { fields: ['scheduled_at'] },
+ ],
+});
+
+module.exports = Notification;
diff --git a/models/NotificationLog.js b/models/NotificationLog.js
new file mode 100644
index 0000000..8f74bb8
--- /dev/null
+++ b/models/NotificationLog.js
@@ -0,0 +1,33 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const NotificationLog = sequelize.define('notification_logs', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ notification_id: { type: DataTypes.UUID, allowNull: false },
+ user_id: { type: DataTypes.UUID, allowNull: false },
+ sent_at: { type: DataTypes.DATE },
+ read_at: { type: DataTypes.DATE },
+ delivery_status: {
+ type: DataTypes.ENUM('pending', 'sent', 'delivered', 'failed', 'read'),
+ defaultValue: 'pending'
+ },
+ delivery_channel: {
+ type: DataTypes.ENUM('in_app', 'email', 'sms', 'push'),
+ defaultValue: 'in_app'
+ },
+ error_message: { type: DataTypes.TEXT },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'notification_logs',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['notification_id'] },
+ { fields: ['user_id'] },
+ { fields: ['delivery_status'] },
+ { fields: ['read_at'] },
+ ],
+});
+
+module.exports = NotificationLog;
diff --git a/models/ParentAssignedTask.js b/models/ParentAssignedTask.js
new file mode 100644
index 0000000..8be050d
--- /dev/null
+++ b/models/ParentAssignedTask.js
@@ -0,0 +1,95 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * ParentAssignedTask Model - Phụ huynh gán bài tập cho con
+ */
+const ParentAssignedTask = sequelize.define('parent_assigned_tasks', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ parent_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'users_auth',
+ key: 'id',
+ },
+ comment: 'Phụ huynh gán',
+ },
+ student_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'student_details',
+ key: 'id',
+ },
+ comment: 'Học sinh được gán',
+ },
+ grade_item_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'grade_items',
+ key: 'id',
+ },
+ comment: 'Bài tập được gán',
+ },
+ assigned_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ comment: 'Thời gian gán',
+ },
+ due_date: {
+ type: DataTypes.DATEONLY,
+ comment: 'Hạn hoàn thành',
+ },
+ status: {
+ type: DataTypes.ENUM('pending', 'in_progress', 'completed', 'overdue'),
+ defaultValue: 'pending',
+ comment: 'Trạng thái',
+ },
+ message: {
+ type: DataTypes.TEXT,
+ comment: 'Lời nhắn của phụ huynh',
+ },
+ priority: {
+ type: DataTypes.ENUM('low', 'normal', 'high'),
+ defaultValue: 'normal',
+ comment: 'Độ ưu tiên',
+ },
+ completion_date: {
+ type: DataTypes.DATE,
+ comment: 'Ngày hoàn thành',
+ },
+ student_notes: {
+ type: DataTypes.TEXT,
+ comment: 'Ghi chú của học sinh khi hoàn thành',
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+ updated_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+}, {
+ tableName: 'parent_assigned_tasks',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['parent_id'] },
+ { fields: ['student_id'] },
+ { fields: ['status'] },
+ { fields: ['due_date'] },
+ ],
+ comment: 'Bảng quản lý bài tập phụ huynh gán cho con',
+});
+
+module.exports = ParentAssignedTask;
diff --git a/models/ParentStudentMap.js b/models/ParentStudentMap.js
new file mode 100644
index 0000000..703e184
--- /dev/null
+++ b/models/ParentStudentMap.js
@@ -0,0 +1,24 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const ParentStudentMap = sequelize.define('parent_student_map', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ parent_id: { type: DataTypes.UUID, allowNull: false },
+ student_id: { type: DataTypes.UUID, allowNull: false },
+ relationship: { type: DataTypes.ENUM('father', 'mother', 'guardian', 'other'), allowNull: false },
+ is_primary_contact: { type: DataTypes.BOOLEAN, defaultValue: false },
+ can_assign_tasks: { type: DataTypes.BOOLEAN, defaultValue: true, comment: 'Quyền gán bài tập' },
+ can_view_detailed_grades: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Xem điểm chi tiết' },
+ can_communicate_teacher: { type: DataTypes.BOOLEAN, defaultValue: true, comment: 'Liên hệ giáo viên' },
+ permission_level: { type: DataTypes.ENUM('limited', 'standard', 'full'), defaultValue: 'standard', comment: 'Cấp quyền' },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'parent_student_map',
+ timestamps: false,
+ underscored: true,
+ indexes: [
+ { fields: ['can_assign_tasks', 'can_view_detailed_grades'] },
+ ],
+});
+
+module.exports = ParentStudentMap;
diff --git a/models/Permission.js b/models/Permission.js
new file mode 100644
index 0000000..150874d
--- /dev/null
+++ b/models/Permission.js
@@ -0,0 +1,24 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const Permission = sequelize.define('permissions', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ permission_code: { type: DataTypes.STRING(50), unique: true, allowNull: false },
+ permission_name: { type: DataTypes.STRING(100), allowNull: false },
+ permission_description: { type: DataTypes.TEXT },
+ resource: { type: DataTypes.STRING(50), comment: 'Tài nguyên (students, grades, etc.)' },
+ action: { type: DataTypes.STRING(50), comment: 'Hành động (view, create, edit, delete)' },
+ is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'permissions',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['permission_code'], unique: true },
+ { fields: ['resource', 'action'] },
+ ],
+});
+
+module.exports = Permission;
diff --git a/models/Role.js b/models/Role.js
new file mode 100644
index 0000000..cd40c7e
--- /dev/null
+++ b/models/Role.js
@@ -0,0 +1,64 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * Roles Model - Danh sách 9 vai trò trong hệ thống
+ */
+const Role = sequelize.define('roles', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ role_code: {
+ type: DataTypes.STRING(50),
+ unique: true,
+ allowNull: false,
+ comment: 'Mã vai trò (system_admin, center_manager, etc.)',
+ },
+ role_name: {
+ type: DataTypes.STRING(100),
+ allowNull: false,
+ comment: 'Tên vai trò',
+ },
+ role_description: {
+ type: DataTypes.TEXT,
+ comment: 'Mô tả vai trò',
+ },
+ level: {
+ type: DataTypes.INTEGER,
+ defaultValue: 0,
+ comment: 'Cấp độ vai trò (0=cao nhất)',
+ },
+ is_system_role: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ comment: 'Vai trò hệ thống không thể xóa',
+ },
+ is_active: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: true,
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+ updated_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+}, {
+ tableName: 'roles',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['role_code'], unique: true },
+ { fields: ['level'] },
+ { fields: ['is_active'] },
+ ],
+ comment: 'Bảng quản lý vai trò (9 vai trò)',
+});
+
+module.exports = Role;
diff --git a/models/RolePermission.js b/models/RolePermission.js
new file mode 100644
index 0000000..bb57187
--- /dev/null
+++ b/models/RolePermission.js
@@ -0,0 +1,18 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const RolePermission = sequelize.define('role_permissions', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ role_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'roles', key: 'id' } },
+ permission_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'permissions', key: 'id' } },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'role_permissions',
+ timestamps: false,
+ underscored: true,
+ indexes: [
+ { fields: ['role_id', 'permission_id'], unique: true },
+ ],
+});
+
+module.exports = RolePermission;
diff --git a/models/Room.js b/models/Room.js
new file mode 100644
index 0000000..a6e52ce
--- /dev/null
+++ b/models/Room.js
@@ -0,0 +1,20 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const Room = sequelize.define('rooms', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ room_code: { type: DataTypes.STRING(20), allowNull: false },
+ room_name: { type: DataTypes.STRING(100), allowNull: false },
+ school_id: { type: DataTypes.UUID, allowNull: false },
+ room_type: { type: DataTypes.ENUM('classroom', 'lab', 'library', 'gym', 'other') },
+ capacity: { type: DataTypes.INTEGER },
+ floor: { type: DataTypes.INTEGER },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'rooms',
+ timestamps: true,
+ underscored: true,
+});
+
+module.exports = Room;
diff --git a/models/School.js b/models/School.js
new file mode 100644
index 0000000..63919c5
--- /dev/null
+++ b/models/School.js
@@ -0,0 +1,83 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * Schools Model - Quản lý thông tin 200 trường
+ */
+const School = sequelize.define('schools', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ school_code: {
+ type: DataTypes.STRING(20),
+ unique: false,
+ allowNull: false,
+ comment: 'Mã trường duy nhất',
+ },
+ school_name: {
+ type: DataTypes.STRING(200),
+ allowNull: false,
+ comment: 'Tên trường',
+ },
+ school_type: {
+ type: DataTypes.ENUM('preschool', 'primary', 'secondary', 'high_school'),
+ allowNull: false,
+ comment: 'Loại trường',
+ },
+ address: {
+ type: DataTypes.TEXT,
+ comment: 'Địa chỉ trường',
+ },
+ city: {
+ type: DataTypes.STRING(100),
+ comment: 'Thành phố',
+ },
+ district: {
+ type: DataTypes.STRING(100),
+ comment: 'Quận/Huyện',
+ },
+ phone: {
+ type: DataTypes.STRING(20),
+ comment: 'Số điện thoại',
+ },
+ logo: {
+ type: DataTypes.TEXT,
+ allowNull: true,
+ comment: 'URL logo của trường',
+ },
+ config: {
+ type: DataTypes.JSON,
+ defaultValue: {},
+ comment: 'Cấu hình riêng của trường (JSON)',
+ },
+ is_active: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: true,
+ comment: 'Trạng thái hoạt động',
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+ updated_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+}, {
+ tableName: 'schools',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['school_code'], unique: true },
+ { fields: ['school_type'] },
+ { fields: ['is_active'] },
+ { fields: ['city', 'district'] },
+ ],
+ comment: 'Bảng quản lý thông tin 200 trường học',
+});
+
+module.exports = School;
diff --git a/models/StaffAchievement.js b/models/StaffAchievement.js
new file mode 100644
index 0000000..d6c5d5f
--- /dev/null
+++ b/models/StaffAchievement.js
@@ -0,0 +1,92 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * StaffAchievement Model - Lưu trữ chứng chỉ và thành tích đào tạo
+ */
+const StaffAchievement = sequelize.define('staff_achievements', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ staff_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'users_auth',
+ key: 'id',
+ },
+ comment: 'Nhân viên',
+ },
+ course_id: {
+ type: DataTypes.UUID,
+ references: {
+ model: 'subjects',
+ key: 'id',
+ },
+ comment: 'Khóa học trong hệ thống',
+ },
+ course_name: {
+ type: DataTypes.STRING(200),
+ allowNull: false,
+ comment: 'Tên khóa học',
+ },
+ completion_date: {
+ type: DataTypes.DATEONLY,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ comment: 'Ngày hoàn thành',
+ },
+ certificate_url: {
+ type: DataTypes.STRING(500),
+ comment: 'URL file chứng chỉ',
+ },
+ certificate_code: {
+ type: DataTypes.STRING(100),
+ comment: 'Mã chứng chỉ',
+ },
+ type: {
+ type: DataTypes.ENUM('mandatory', 'self_study', 'external'),
+ allowNull: false,
+ comment: 'Loại: bắt buộc, tự học, bên ngoài',
+ },
+ score: {
+ type: DataTypes.DECIMAL(5, 2),
+ comment: 'Điểm số (0-100)',
+ },
+ total_hours: {
+ type: DataTypes.INTEGER,
+ comment: 'Tổng số giờ học',
+ },
+ verified_by: {
+ type: DataTypes.UUID,
+ references: {
+ model: 'users_auth',
+ key: 'id',
+ },
+ comment: 'Admin xác nhận',
+ },
+ verified_at: {
+ type: DataTypes.DATE,
+ comment: 'Thời gian xác nhận',
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+}, {
+ tableName: 'staff_achievements',
+ timestamps: false,
+ underscored: true,
+ indexes: [
+ { fields: ['staff_id'] },
+ { fields: ['type'] },
+ { fields: ['completion_date'] },
+ { fields: ['certificate_code'] },
+ ],
+ comment: 'Bảng lưu trữ chứng chỉ và thành tích đào tạo',
+});
+
+module.exports = StaffAchievement;
diff --git a/models/StaffContract.js b/models/StaffContract.js
new file mode 100644
index 0000000..caff0e4
--- /dev/null
+++ b/models/StaffContract.js
@@ -0,0 +1,21 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const StaffContract = sequelize.define('staff_contracts', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ teacher_id: { type: DataTypes.UUID, allowNull: false },
+ school_id: { type: DataTypes.UUID, allowNull: false },
+ contract_type: { type: DataTypes.ENUM('full_time', 'part_time', 'contract'), allowNull: false },
+ start_date: { type: DataTypes.DATE, allowNull: false },
+ end_date: { type: DataTypes.DATE },
+ salary: { type: DataTypes.DECIMAL(12, 2) },
+ status: { type: DataTypes.ENUM('active', 'expired', 'terminated'), defaultValue: 'active' },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'staff_contracts',
+ timestamps: true,
+ underscored: true,
+});
+
+module.exports = StaffContract;
diff --git a/models/StaffTrainingAssignment.js b/models/StaffTrainingAssignment.js
new file mode 100644
index 0000000..5e13220
--- /dev/null
+++ b/models/StaffTrainingAssignment.js
@@ -0,0 +1,90 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * StaffTrainingAssignment Model - Quản lý phân công đào tạo nhân sự
+ */
+const StaffTrainingAssignment = sequelize.define('staff_training_assignments', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ staff_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'users_auth',
+ key: 'id',
+ },
+ comment: 'Nhân viên được giao',
+ },
+ subject_id: {
+ type: DataTypes.UUID,
+ references: {
+ model: 'subjects',
+ key: 'id',
+ },
+ comment: 'Khóa học/môn học bắt buộc',
+ },
+ course_name: {
+ type: DataTypes.STRING(200),
+ comment: 'Tên khóa học (nếu không có subject_id)',
+ },
+ assigned_by: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'users_auth',
+ key: 'id',
+ },
+ comment: 'Admin giao nhiệm vụ',
+ },
+ assigned_date: {
+ type: DataTypes.DATEONLY,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ comment: 'Ngày giao',
+ },
+ deadline: {
+ type: DataTypes.DATEONLY,
+ comment: 'Hạn hoàn thành',
+ },
+ status: {
+ type: DataTypes.ENUM('pending', 'in_progress', 'completed', 'overdue'),
+ defaultValue: 'pending',
+ comment: 'Trạng thái',
+ },
+ priority: {
+ type: DataTypes.ENUM('low', 'normal', 'high', 'urgent'),
+ defaultValue: 'normal',
+ comment: 'Độ ưu tiên',
+ },
+ notes: {
+ type: DataTypes.TEXT,
+ comment: 'Ghi chú',
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+ updated_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+}, {
+ tableName: 'staff_training_assignments',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['staff_id'] },
+ { fields: ['status'] },
+ { fields: ['deadline'] },
+ { fields: ['assigned_by'] },
+ ],
+ comment: 'Bảng quản lý phân công đào tạo nhân sự',
+});
+
+module.exports = StaffTrainingAssignment;
diff --git a/models/StudentDetail.js b/models/StudentDetail.js
new file mode 100644
index 0000000..483e17d
--- /dev/null
+++ b/models/StudentDetail.js
@@ -0,0 +1,24 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const StudentDetail = sequelize.define('student_details', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ user_id: { type: DataTypes.UUID, unique: true, allowNull: false, references: { model: 'user_profiles', key: 'user_id' } },
+ student_code: { type: DataTypes.STRING(50), unique: true, allowNull: false },
+ enrollment_date: { type: DataTypes.DATE },
+ current_class_id: { type: DataTypes.UUID, references: { model: 'classes', key: 'id' } },
+ status: { type: DataTypes.ENUM('active', 'on_leave', 'graduated', 'dropped'), defaultValue: 'active' },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'student_details',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['user_id'], unique: true },
+ { fields: ['student_code'], unique: true },
+ { fields: ['current_class_id'] },
+ ],
+});
+
+module.exports = StudentDetail;
diff --git a/models/Subject.js b/models/Subject.js
new file mode 100644
index 0000000..d594f5d
--- /dev/null
+++ b/models/Subject.js
@@ -0,0 +1,29 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const Subject = sequelize.define('subjects', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ subject_code: { type: DataTypes.STRING(20), unique: true, allowNull: false },
+ subject_name: { type: DataTypes.STRING(100), allowNull: false },
+ subject_name_en: { type: DataTypes.STRING(100) },
+ description: { type: DataTypes.TEXT },
+ is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
+ is_premium: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Nội dung premium' },
+ is_training: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Nội dung đào tạo nhân sự' },
+ is_public: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Nội dung tự học công khai' },
+ required_role: { type: DataTypes.STRING(50), comment: 'Role yêu cầu' },
+ min_subscription_tier: { type: DataTypes.STRING(50), comment: 'Gói tối thiểu: basic, premium, vip' },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'subjects',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['is_premium'] },
+ { fields: ['is_training'] },
+ { fields: ['is_public'] },
+ ],
+});
+
+module.exports = Subject;
diff --git a/models/SubscriptionPlan.js b/models/SubscriptionPlan.js
new file mode 100644
index 0000000..c429895
--- /dev/null
+++ b/models/SubscriptionPlan.js
@@ -0,0 +1,69 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * SubscriptionPlan Model - Định nghĩa các gói thẻ tháng
+ */
+const SubscriptionPlan = sequelize.define('subscription_plans', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ name: {
+ type: DataTypes.STRING(100),
+ allowNull: false,
+ comment: 'Tên gói: VIP Học sinh, Premium Giáo viên, etc.',
+ },
+ price: {
+ type: DataTypes.DECIMAL(10, 2),
+ allowNull: false,
+ comment: 'Giá tiền (VND)',
+ },
+ duration_days: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ defaultValue: 30,
+ comment: 'Thời hạn gói (ngày)',
+ },
+ target_role: {
+ type: DataTypes.STRING(50),
+ allowNull: false,
+ comment: 'Role được phép mua: student, parent, teacher',
+ },
+ description: {
+ type: DataTypes.TEXT,
+ comment: 'Mô tả gói',
+ },
+ features: {
+ type: DataTypes.JSON,
+ comment: 'Danh sách tính năng: ["detailed_reports", "priority_support"]',
+ },
+ is_active: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: true,
+ comment: 'Trạng thái kích hoạt',
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+ updated_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+}, {
+ tableName: 'subscription_plans',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['target_role'] },
+ { fields: ['is_active'] },
+ { fields: ['price'] },
+ ],
+ comment: 'Bảng định nghĩa các gói thẻ tháng',
+});
+
+module.exports = SubscriptionPlan;
diff --git a/models/TeacherDetail.js b/models/TeacherDetail.js
new file mode 100644
index 0000000..f9784cb
--- /dev/null
+++ b/models/TeacherDetail.js
@@ -0,0 +1,31 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const TeacherDetail = sequelize.define('teacher_details', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ user_id: { type: DataTypes.UUID, unique: true, allowNull: false, references: { model: 'user_profiles', key: 'user_id' } },
+ teacher_code: { type: DataTypes.STRING(50), unique: true, allowNull: false },
+ teacher_type: { type: DataTypes.ENUM('foreign', 'assistant', 'homeroom', 'specialist'), allowNull: false },
+ qualification: { type: DataTypes.STRING(200) },
+ specialization: { type: DataTypes.STRING(200) },
+ hire_date: { type: DataTypes.DATE },
+ status: { type: DataTypes.ENUM('active', 'on_leave', 'resigned'), defaultValue: 'active' },
+ skill_tags: { type: DataTypes.JSON, comment: 'Array of skills: [{"name": "Python", "level": "expert"}]' },
+ certifications: { type: DataTypes.JSON, comment: 'Array of certificates' },
+ training_hours: { type: DataTypes.INTEGER, defaultValue: 0, comment: 'Tổng giờ đào tạo' },
+ last_training_date: { type: DataTypes.DATEONLY, comment: 'Ngày đào tạo gần nhất' },
+ training_score_avg: { type: DataTypes.DECIMAL(5, 2), comment: 'Điểm TB các khóa học' },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'teacher_details',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['user_id'], unique: true },
+ { fields: ['teacher_code'], unique: true },
+ { fields: ['teacher_type'] },
+ ],
+});
+
+module.exports = TeacherDetail;
diff --git a/models/UserAssignment.js b/models/UserAssignment.js
new file mode 100644
index 0000000..490f13d
--- /dev/null
+++ b/models/UserAssignment.js
@@ -0,0 +1,27 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const UserAssignment = sequelize.define('user_assignments', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ user_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'users_auth', key: 'id' } },
+ role_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'roles', key: 'id' } },
+ school_id: { type: DataTypes.UUID, references: { model: 'schools', key: 'id' } },
+ class_id: { type: DataTypes.UUID, references: { model: 'classes', key: 'id' } },
+ is_primary: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Vai trò chính' },
+ valid_from: { type: DataTypes.DATE },
+ valid_until: { type: DataTypes.DATE },
+ is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
+ created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+}, {
+ tableName: 'user_assignments',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['user_id', 'role_id', 'school_id'] },
+ { fields: ['school_id'] },
+ { fields: ['is_active'] },
+ ],
+});
+
+module.exports = UserAssignment;
diff --git a/models/UserProfile.js b/models/UserProfile.js
new file mode 100644
index 0000000..c14870c
--- /dev/null
+++ b/models/UserProfile.js
@@ -0,0 +1,79 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+const UserProfile = sequelize.define('user_profiles', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ user_id: {
+ type: DataTypes.UUID,
+ unique: true,
+ allowNull: false,
+ references: {
+ model: 'users_auth',
+ key: 'id',
+ },
+ },
+ full_name: {
+ type: DataTypes.STRING(100),
+ allowNull: false,
+ },
+ first_name: {
+ type: DataTypes.STRING(50),
+ },
+ last_name: {
+ type: DataTypes.STRING(50),
+ },
+ date_of_birth: {
+ type: DataTypes.DATE,
+ },
+ gender: {
+ type: DataTypes.ENUM('male', 'female', 'other'),
+ },
+ phone: {
+ type: DataTypes.STRING(20),
+ },
+ avatar_url: {
+ type: DataTypes.STRING(500),
+ },
+ address: {
+ type: DataTypes.TEXT,
+ },
+ school_id: {
+ type: DataTypes.UUID,
+ },
+ city: {
+ type: DataTypes.STRING(100),
+ },
+ district: {
+ type: DataTypes.STRING(100),
+ },
+ etc: {
+ type: DataTypes.JSON,
+ defaultValue: {},
+ comment: 'Thông tin bổ sung (JSONB)',
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+ updated_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+}, {
+ tableName: 'user_profiles',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['user_id'], unique: true },
+ { fields: ['full_name'] },
+ { fields: ['phone'] },
+ ],
+});
+
+module.exports = UserProfile;
diff --git a/models/UserSubscription.js b/models/UserSubscription.js
new file mode 100644
index 0000000..3ce5c55
--- /dev/null
+++ b/models/UserSubscription.js
@@ -0,0 +1,87 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * UserSubscription Model - Quản lý subscription của từng user
+ */
+const UserSubscription = sequelize.define('user_subscriptions', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ user_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'users_auth',
+ key: 'id',
+ },
+ comment: 'User mua gói',
+ },
+ plan_id: {
+ type: DataTypes.UUID,
+ allowNull: false,
+ references: {
+ model: 'subscription_plans',
+ key: 'id',
+ },
+ comment: 'Gói đã mua',
+ },
+ start_date: {
+ type: DataTypes.DATEONLY,
+ allowNull: false,
+ comment: 'Ngày bắt đầu',
+ },
+ end_date: {
+ type: DataTypes.DATEONLY,
+ allowNull: false,
+ comment: 'Ngày kết thúc',
+ },
+ status: {
+ type: DataTypes.ENUM('active', 'expired', 'cancelled'),
+ defaultValue: 'active',
+ allowNull: false,
+ comment: 'Trạng thái subscription',
+ },
+ transaction_id: {
+ type: DataTypes.STRING(100),
+ comment: 'Mã giao dịch để đối soát',
+ },
+ payment_method: {
+ type: DataTypes.STRING(50),
+ comment: 'Phương thức thanh toán: momo, vnpay, banking',
+ },
+ payment_amount: {
+ type: DataTypes.DECIMAL(10, 2),
+ comment: 'Số tiền đã thanh toán',
+ },
+ auto_renew: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ comment: 'Tự động gia hạn',
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+ updated_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+}, {
+ tableName: 'user_subscriptions',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['user_id'] },
+ { fields: ['status'] },
+ { fields: ['end_date'] },
+ { fields: ['transaction_id'] },
+ ],
+ comment: 'Bảng quản lý subscription của user',
+});
+
+module.exports = UserSubscription;
diff --git a/models/UsersAuth.js b/models/UsersAuth.js
new file mode 100644
index 0000000..185a503
--- /dev/null
+++ b/models/UsersAuth.js
@@ -0,0 +1,112 @@
+const { DataTypes } = require('sequelize');
+const { sequelize } = require('../config/database');
+
+/**
+ * UsersAuth Model - Thông tin đăng nhập người dùng
+ */
+const UsersAuth = sequelize.define('users_auth', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true,
+ },
+ username: {
+ type: DataTypes.STRING(50),
+ unique: true,
+ allowNull: false,
+ comment: 'Tên đăng nhập duy nhất',
+ },
+ email: {
+ type: DataTypes.STRING(100),
+ unique: true,
+ allowNull: false,
+ validate: {
+ isEmail: true,
+ },
+ comment: 'Email đăng nhập',
+ },
+ password_hash: {
+ type: DataTypes.STRING(255),
+ allowNull: false,
+ comment: 'Mật khẩu đã hash',
+ },
+ salt: {
+ type: DataTypes.STRING(255),
+ allowNull: false,
+ comment: 'Salt cho mật khẩu',
+ },
+ qr_version: {
+ type: DataTypes.INTEGER,
+ defaultValue: 1,
+ comment: 'Phiên bản mã QR cho điểm danh',
+ },
+ qr_secret: {
+ type: DataTypes.STRING(255),
+ comment: 'Secret key cho QR code',
+ },
+ current_session_id: {
+ type: DataTypes.STRING(255),
+ comment: 'ID phiên đăng nhập hiện tại',
+ },
+ last_login: {
+ type: DataTypes.DATE,
+ comment: 'Lần đăng nhập cuối',
+ },
+ last_login_ip: {
+ type: DataTypes.STRING(50),
+ comment: 'IP đăng nhập cuối',
+ },
+ login_count: {
+ type: DataTypes.INTEGER,
+ defaultValue: 0,
+ comment: 'Số lần đăng nhập',
+ },
+ login_attempts: {
+ type: DataTypes.INTEGER,
+ defaultValue: 0,
+ comment: 'Số lần thử đăng nhập',
+ },
+ is_active: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: true,
+ comment: 'Trạng thái tài khoản',
+ },
+ is_locked: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false,
+ comment: 'Tài khoản bị khóa',
+ },
+ failed_login_attempts: {
+ type: DataTypes.INTEGER,
+ defaultValue: 0,
+ comment: 'Số lần đăng nhập thất bại',
+ },
+ locked_until: {
+ type: DataTypes.DATE,
+ comment: 'Khóa tài khoản đến',
+ },
+ created_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+ updated_at: {
+ type: DataTypes.DATE,
+ allowNull: false,
+ defaultValue: DataTypes.NOW,
+ },
+}, {
+ tableName: 'users_auth',
+ timestamps: true,
+ underscored: true,
+ indexes: [
+ { fields: ['username'], unique: true },
+ { fields: ['email'], unique: true },
+ { fields: ['is_active'] },
+ { fields: ['qr_version'] },
+ { fields: ['current_session_id'] },
+ ],
+ comment: 'Bảng quản lý thông tin đăng nhập người dùng',
+});
+
+module.exports = UsersAuth;
diff --git a/models/index.js b/models/index.js
new file mode 100644
index 0000000..11c493c
--- /dev/null
+++ b/models/index.js
@@ -0,0 +1,271 @@
+const { sequelize } = require('../config/database');
+
+/**
+ * Import all models
+ */
+// Group 1: System & Authorization
+const School = require('./School');
+const UsersAuth = require('./UsersAuth');
+const Role = require('./Role');
+const Permission = require('./Permission');
+const RolePermission = require('./RolePermission');
+const UserAssignment = require('./UserAssignment');
+
+// Group 2: User Profiles
+const UserProfile = require('./UserProfile');
+const StudentDetail = require('./StudentDetail');
+const TeacherDetail = require('./TeacherDetail');
+const ParentStudentMap = require('./ParentStudentMap');
+const StaffContract = require('./StaffContract');
+
+// Group 3: Academic Structure
+const AcademicYear = require('./AcademicYear');
+const Subject = require('./Subject');
+const Class = require('./Class');
+const ClassSchedule = require('./ClassSchedule');
+const Room = require('./Room');
+
+// Group 3.1: Learning Content (NEW)
+const Chapter = require('./Chapter');
+const Lesson = require('./Lesson');
+const Game = require('./Game');
+
+// Group 4: Attendance
+const AttendanceLog = require('./AttendanceLog');
+const AttendanceDaily = require('./AttendanceDaily');
+const LeaveRequest = require('./LeaveRequest');
+
+// Group 5: Gradebook
+const GradeCategory = require('./GradeCategory');
+const GradeItem = require('./GradeItem');
+const Grade = require('./Grade');
+const GradeSummary = require('./GradeSummary');
+const GradeHistory = require('./GradeHistory');
+
+// Group 6: Notifications
+const Notification = require('./Notification');
+const NotificationLog = require('./NotificationLog');
+const Message = require('./Message');
+const AuditLog = require('./AuditLog');
+
+// Group 7: Subscription & Training
+const SubscriptionPlan = require('./SubscriptionPlan');
+const UserSubscription = require('./UserSubscription');
+const StaffTrainingAssignment = require('./StaffTrainingAssignment');
+const StaffAchievement = require('./StaffAchievement');
+const ParentAssignedTask = require('./ParentAssignedTask');
+
+/**
+ * Define relationships between models
+ */
+const setupRelationships = () => {
+ // UsersAuth relationships
+ UsersAuth.hasOne(UserProfile, { foreignKey: 'user_id', as: 'profile' });
+ UsersAuth.hasMany(UserAssignment, { foreignKey: 'user_id', as: 'assignments' });
+
+ // UserProfile relationships
+ UserProfile.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'auth' });
+ UserProfile.belongsTo(School, { foreignKey: 'school_id', as: 'school' });
+ UserProfile.hasOne(StudentDetail, { foreignKey: 'user_id', as: 'studentDetail' });
+ UserProfile.hasOne(TeacherDetail, { foreignKey: 'user_id', as: 'teacherDetail' });
+
+ // School relationships
+ School.hasMany(Class, { foreignKey: 'school_id', as: 'classes' });
+ School.hasMany(Room, { foreignKey: 'school_id', as: 'rooms' });
+ School.hasMany(UserAssignment, { foreignKey: 'school_id', as: 'assignments' });
+ School.hasMany(AttendanceLog, { foreignKey: 'school_id', as: 'attendanceLogs' });
+
+ // Role & Permission relationships
+ Role.belongsToMany(Permission, {
+ through: RolePermission,
+ foreignKey: 'role_id',
+ otherKey: 'permission_id',
+ as: 'permissions'
+ });
+ Permission.belongsToMany(Role, {
+ through: RolePermission,
+ foreignKey: 'permission_id',
+ otherKey: 'role_id',
+ as: 'roles'
+ });
+
+ // UserAssignment relationships
+ UserAssignment.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'user' });
+ UserAssignment.belongsTo(Role, { foreignKey: 'role_id', as: 'role' });
+ UserAssignment.belongsTo(School, { foreignKey: 'school_id', as: 'school' });
+ UserAssignment.belongsTo(Class, { foreignKey: 'class_id', as: 'class' });
+
+ // StudentDetail relationships
+ StudentDetail.belongsTo(UserProfile, {
+ foreignKey: 'user_id',
+ targetKey: 'user_id',
+ as: 'profile'
+ });
+ StudentDetail.belongsTo(Class, { foreignKey: 'current_class_id', as: 'currentClass' });
+ StudentDetail.belongsToMany(UserProfile, {
+ through: ParentStudentMap,
+ foreignKey: 'student_id',
+ otherKey: 'parent_id',
+ as: 'parents'
+ });
+
+ // TeacherDetail relationships
+ TeacherDetail.belongsTo(UserProfile, { foreignKey: 'user_id', as: 'profile' });
+ TeacherDetail.hasMany(StaffContract, { foreignKey: 'teacher_id', as: 'contracts' });
+
+ // Class relationships
+ Class.belongsTo(School, { foreignKey: 'school_id', as: 'school' });
+ Class.belongsTo(AcademicYear, { foreignKey: 'academic_year_id', as: 'academicYear' });
+ Class.belongsTo(TeacherDetail, { foreignKey: 'homeroom_teacher_id', as: 'homeroomTeacher' });
+ Class.hasMany(StudentDetail, { foreignKey: 'current_class_id', as: 'students' });
+ Class.hasMany(ClassSchedule, { foreignKey: 'class_id', as: 'schedules' });
+ Class.hasMany(Grade, { foreignKey: 'class_id', as: 'grades' });
+
+ // ClassSchedule relationships
+ ClassSchedule.belongsTo(Class, { foreignKey: 'class_id', as: 'class' });
+ ClassSchedule.belongsTo(Subject, { foreignKey: 'subject_id', as: 'subject' });
+ ClassSchedule.belongsTo(Room, { foreignKey: 'room_id', as: 'room' });
+ ClassSchedule.belongsTo(TeacherDetail, { foreignKey: 'teacher_id', as: 'teacher' });
+
+ // Learning Content relationships (NEW)
+ // Subject -> Chapter (1:N)
+ Subject.hasMany(Chapter, { foreignKey: 'subject_id', as: 'chapters' });
+ Chapter.belongsTo(Subject, { foreignKey: 'subject_id', as: 'subject' });
+
+ // Chapter -> Lesson (1:N)
+ Chapter.hasMany(Lesson, { foreignKey: 'chapter_id', as: 'lessons' });
+ Lesson.belongsTo(Chapter, { foreignKey: 'chapter_id', as: 'chapter' });
+
+ // Attendance relationships
+ AttendanceLog.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'user' });
+ AttendanceLog.belongsTo(School, { foreignKey: 'school_id', as: 'school' });
+
+ AttendanceDaily.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'user' });
+ AttendanceDaily.belongsTo(School, { foreignKey: 'school_id', as: 'school' });
+ AttendanceDaily.belongsTo(Class, { foreignKey: 'class_id', as: 'class' });
+
+ LeaveRequest.belongsTo(StudentDetail, { foreignKey: 'student_id', as: 'student' });
+ LeaveRequest.belongsTo(UserProfile, { foreignKey: 'requested_by', as: 'requester' });
+ LeaveRequest.belongsTo(TeacherDetail, { foreignKey: 'approved_by', as: 'approver' });
+
+ // Grade relationships
+ GradeCategory.belongsTo(Subject, { foreignKey: 'subject_id', as: 'subject' });
+ GradeCategory.hasMany(GradeItem, { foreignKey: 'category_id', as: 'items' });
+
+ GradeItem.belongsTo(GradeCategory, { foreignKey: 'category_id', as: 'category' });
+ GradeItem.belongsTo(Class, { foreignKey: 'class_id', as: 'class' });
+ GradeItem.hasMany(Grade, { foreignKey: 'grade_item_id', as: 'grades' });
+
+ Grade.belongsTo(GradeItem, { foreignKey: 'grade_item_id', as: 'gradeItem' });
+ Grade.belongsTo(StudentDetail, { foreignKey: 'student_id', as: 'student' });
+ Grade.belongsTo(Class, { foreignKey: 'class_id', as: 'class' });
+ Grade.belongsTo(TeacherDetail, { foreignKey: 'graded_by', as: 'gradedBy' });
+ Grade.hasMany(GradeHistory, { foreignKey: 'grade_id', as: 'history' });
+
+ GradeSummary.belongsTo(StudentDetail, { foreignKey: 'student_id', as: 'student' });
+ GradeSummary.belongsTo(Subject, { foreignKey: 'subject_id', as: 'subject' });
+ GradeSummary.belongsTo(AcademicYear, { foreignKey: 'academic_year_id', as: 'academicYear' });
+
+ // Notification relationships
+ Notification.hasMany(NotificationLog, { foreignKey: 'notification_id', as: 'logs' });
+ NotificationLog.belongsTo(Notification, { foreignKey: 'notification_id', as: 'notification' });
+ NotificationLog.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'user' });
+
+ Message.belongsTo(UserProfile, { foreignKey: 'sender_id', as: 'sender' });
+ Message.belongsTo(UserProfile, { foreignKey: 'recipient_id', as: 'recipient' });
+
+ // Subscription relationships
+ SubscriptionPlan.hasMany(UserSubscription, { foreignKey: 'plan_id', as: 'subscriptions' });
+ UserSubscription.belongsTo(SubscriptionPlan, { foreignKey: 'plan_id', as: 'plan' });
+ UserSubscription.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'user' });
+
+ // Training relationships
+ StaffTrainingAssignment.belongsTo(UsersAuth, { foreignKey: 'staff_id', as: 'staff' });
+ StaffTrainingAssignment.belongsTo(UsersAuth, { foreignKey: 'assigned_by', as: 'assigner' });
+ StaffTrainingAssignment.belongsTo(Subject, { foreignKey: 'subject_id', as: 'subject' });
+
+ StaffAchievement.belongsTo(UsersAuth, { foreignKey: 'staff_id', as: 'staff' });
+ StaffAchievement.belongsTo(Subject, { foreignKey: 'course_id', as: 'course' });
+ StaffAchievement.belongsTo(UsersAuth, { foreignKey: 'verified_by', as: 'verifier' });
+
+ // Parent assigned task relationships
+ ParentAssignedTask.belongsTo(UsersAuth, { foreignKey: 'parent_id', as: 'parent' });
+ ParentAssignedTask.belongsTo(StudentDetail, { foreignKey: 'student_id', as: 'student' });
+ ParentAssignedTask.belongsTo(GradeItem, { foreignKey: 'grade_item_id', as: 'gradeItem' });
+
+ console.log('✅ Model relationships configured');
+};
+
+/**
+ * Sync database (only in development)
+ */
+const syncDatabase = async (options = {}) => {
+ try {
+ await sequelize.sync(options);
+ console.log('✅ Database synchronized');
+ } catch (error) {
+ console.error('❌ Database sync failed:', error.message);
+ throw error;
+ }
+};
+
+/**
+ * Export all models and utilities
+ */
+module.exports = {
+ sequelize,
+ setupRelationships,
+ syncDatabase,
+
+ // Group 1: System & Authorization
+ School,
+ UsersAuth,
+ Role,
+ Permission,
+ RolePermission,
+ UserAssignment,
+
+ // Group 2: User Profiles
+ UserProfile,
+ StudentDetail,
+ TeacherDetail,
+ ParentStudentMap,
+ StaffContract,
+
+ // Group 3: Academic Structure
+ AcademicYear,
+ Subject,
+ Class,
+ ClassSchedule,
+ Room,
+
+ // Group 3.1: Learning Content (NEW)
+ Chapter,
+ Lesson,
+ Game,
+
+ // Group 4: Attendance
+ AttendanceLog,
+ AttendanceDaily,
+ LeaveRequest,
+
+ // Group 5: Gradebook
+ GradeCategory,
+ GradeItem,
+ Grade,
+ GradeSummary,
+ GradeHistory,
+
+ // Group 6: Notifications
+ Notification,
+ NotificationLog,
+ Message,
+ AuditLog,
+
+ // Group 7: Subscription & Training
+ SubscriptionPlan,
+ UserSubscription,
+ StaffTrainingAssignment,
+ StaffAchievement,
+ ParentAssignedTask,
+};
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a50509a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "sena-school-management-api",
+ "version": "1.0.0",
+ "description": "School Management System API for 200 schools with BullMQ and Redis caching",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "nodemon server.js",
+ "test": "jest --coverage",
+ "lint": "eslint ."
+ },
+ "keywords": [
+ "expressjs",
+ "sequelize",
+ "redis",
+ "bullmq",
+ "school-management"
+ ],
+ "author": "Sena Team",
+ "license": "MIT",
+ "dependencies": {
+ "express": "^4.18.2",
+ "sequelize": "^6.35.2",
+ "mysql2": "^3.6.5",
+ "ioredis": "^5.3.2",
+ "bullmq": "^5.1.0",
+ "dotenv": "^16.3.1",
+ "helmet": "^7.1.0",
+ "cors": "^2.8.5",
+ "compression": "^1.7.4",
+ "morgan": "^1.10.0",
+ "express-rate-limit": "^7.1.5",
+ "joi": "^17.11.0",
+ "bcrypt": "^5.1.1",
+ "jsonwebtoken": "^9.0.2",
+ "winston": "^3.11.0",
+ "express-async-errors": "^3.1.1"
+ },
+ "devDependencies": {
+ "nodemon": "^3.0.2",
+ "eslint": "^9.17.0",
+ "jest": "^29.7.0",
+ "supertest": "^7.1.3"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=9.0.0"
+ }
+}
diff --git a/public/js/login.js b/public/js/login.js
new file mode 100644
index 0000000..d10f676
--- /dev/null
+++ b/public/js/login.js
@@ -0,0 +1,252 @@
+// API Base URL - thay đổi theo cấu hình của bạn
+const API_BASE_URL = '/api/';
+
+const loginForm = document.getElementById('loginForm');
+const loginBtn = document.getElementById('loginBtn');
+const registerBtn = document.getElementById('registerBtn');
+const verifyTokenBtn = document.getElementById('verifyTokenBtn');
+const logoutBtn = document.getElementById('logoutBtn');
+const getAllUsersBtn = document.getElementById('getAllUsersBtn');
+const testHealthBtn = document.getElementById('testHealthBtn');
+const responseContainer = document.getElementById('responseContainer');
+
+// Load token from localStorage
+let authToken = localStorage.getItem('token');
+
+// Helper function to display response
+function displayResponse(title, data, status) {
+ const statusClass = status >= 200 && status < 300 ? 'success' : 'error';
+ const statusText = status >= 200 && status < 300 ? 'Success' : 'Error';
+
+ responseContainer.innerHTML = `
+
+
${title}
+
Status: ${status} ${statusText}
+
${JSON.stringify(data, null, 2)}
+
+ `;
+}
+
+// Helper function to show loading
+function showLoading(btn) {
+ const originalText = btn.innerHTML;
+ btn.disabled = true;
+ btn.innerHTML = originalText + '';
+ return originalText;
+}
+
+function hideLoading(btn, originalText) {
+ btn.disabled = false;
+ btn.innerHTML = originalText;
+}
+
+// Login form submission
+loginForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const username = document.getElementById('username').value;
+ const password = document.getElementById('password').value;
+
+ const originalText = showLoading(loginBtn);
+
+ try {
+ // Call login endpoint
+ const response = await fetch(`${API_BASE_URL}auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ username: username,
+ password: password,
+ }),
+ });
+
+ const data = await response.json();
+ displayResponse('Login Response', data, response.status);
+
+ if (response.ok && data.data && data.data.token) {
+ // Store token
+ authToken = data.data.token;
+ localStorage.setItem('token', authToken);
+ alert('✅ Đăng nhập thành công!');
+ }
+ } catch (error) {
+ displayResponse('Login Error', {
+ error: error.message,
+ note: 'Không thể kết nối đến server. Hãy đảm bảo server đang chạy.'
+ }, 500);
+ console.error('Error:', error);
+ } finally {
+ hideLoading(loginBtn, originalText);
+ }
+});
+
+// Register button
+registerBtn.addEventListener('click', async () => {
+ const username = prompt('Nhập username:');
+ if (!username) return;
+
+ const email = prompt('Nhập email:');
+ if (!email) return;
+
+ const password = prompt('Nhập password:');
+ if (!password) return;
+
+ const full_name = prompt('Nhập họ tên (optional):');
+
+ const originalText = showLoading(registerBtn);
+
+ try {
+ const response = await fetch(`${API_BASE_URL}auth/register`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ username,
+ email,
+ password,
+ full_name,
+ }),
+ });
+
+ const data = await response.json();
+ displayResponse('Register Response', data, response.status);
+
+ if (response.ok) {
+ alert('✅ Đăng ký thành công! Bạn có thể đăng nhập ngay.');
+ }
+ } catch (error) {
+ displayResponse('Register Error', {
+ error: error.message
+ }, 500);
+ console.error('Error:', error);
+ } finally {
+ hideLoading(registerBtn, originalText);
+ }
+});
+
+// Verify token button
+verifyTokenBtn.addEventListener('click', async () => {
+ const originalText = showLoading(verifyTokenBtn);
+
+ try {
+ if (!authToken) {
+ displayResponse('Verify Token Error', {
+ error: 'Chưa có token. Vui lòng đăng nhập trước.'
+ }, 401);
+ return;
+ }
+
+ const response = await fetch(`${API_BASE_URL}auth/verify-token`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${authToken}`,
+ },
+ });
+
+ const data = await response.json();
+ displayResponse('Verify Token Response', data, response.status);
+ } catch (error) {
+ displayResponse('Verify Token Error', {
+ error: error.message
+ }, 500);
+ console.error('Error:', error);
+ } finally {
+ hideLoading(verifyTokenBtn, originalText);
+ }
+});
+
+// Logout button
+logoutBtn.addEventListener('click', async () => {
+ const originalText = showLoading(logoutBtn);
+
+ try {
+ if (!authToken) {
+ displayResponse('Logout Error', {
+ error: 'Chưa có token. Vui lòng đăng nhập trước.'
+ }, 401);
+ return;
+ }
+
+ const response = await fetch(`${API_BASE_URL}auth/logout`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${authToken}`,
+ },
+ });
+
+ const data = await response.json();
+ displayResponse('Logout Response', data, response.status);
+
+ if (response.ok) {
+ authToken = null;
+ localStorage.removeItem('token');
+ alert('✅ Đăng xuất thành công!');
+ }
+ } catch (error) {
+ displayResponse('Logout Error', {
+ error: error.message
+ }, 500);
+ console.error('Error:', error);
+ } finally {
+ hideLoading(logoutBtn, originalText);
+ }
+});
+
+// Get all users
+getAllUsersBtn.addEventListener('click', async () => {
+ const originalText = showLoading(getAllUsersBtn);
+
+ try {
+ const response = await fetch(`${API_BASE_URL}users?page=1&limit=10`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ const data = await response.json();
+ displayResponse('Get All Users', data, response.status);
+ } catch (error) {
+ displayResponse('Error', {
+ error: error.message,
+ note: 'Không thể kết nối đến API. Hãy đảm bảo server đang chạy tại ' + API_BASE_URL
+ }, 500);
+ console.error('Error:', error);
+ } finally {
+ hideLoading(getAllUsersBtn, originalText);
+ }
+});
+
+// Test health endpoint
+testHealthBtn.addEventListener('click', async () => {
+ const originalText = showLoading(testHealthBtn);
+
+ try {
+ const response = await fetch(`/health`, {
+ method: 'GET',
+ });
+
+ const data = await response.json();
+ displayResponse('Health Check', data, response.status);
+ } catch (error) {
+ displayResponse('Error', {
+ error: error.message,
+ note: 'Không thể kết nối đến API. Hãy đảm bảo server đang chạy tại ' + API_BASE_URL
+ }, 500);
+ console.error('Error:', error);
+ } finally {
+ hideLoading(testHealthBtn, originalText);
+ }
+});
+
+// Auto-test health on page load
+window.addEventListener('load', () => {
+ setTimeout(() => {
+ testHealthBtn.click();
+ }, 500);
+});
diff --git a/public/login.html b/public/login.html
new file mode 100644
index 0000000..5ab213f
--- /dev/null
+++ b/public/login.html
@@ -0,0 +1,290 @@
+
+
+
+
+
+ Sena DB API - Login Test
+
+
+
+
+
+
+
🔐 Login Test
+
Test quy trình đăng nhập API
+
+
+ Endpoints:
+ POST /api/auth/login - Đăng nhập
+ POST /api/auth/register - Đăng ký
+ POST /api/auth/verify-token - Verify token
+ POST /api/auth/logout - Đăng xuất
+ GET /api/users - Danh sách users
+
+
+
+ ✅ API sẵn sàng! Hệ thống đã có đầy đủ các endpoint: login, register, verify-token, logout.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📊 Kết quả
+
Response từ API server
+
+
+
+
Chưa có request nào được gửi
+
+
+
+
+
+
+
+
diff --git a/routes/academicYearRoutes.js b/routes/academicYearRoutes.js
new file mode 100644
index 0000000..5fe4c9a
--- /dev/null
+++ b/routes/academicYearRoutes.js
@@ -0,0 +1,33 @@
+const express = require('express');
+const router = express.Router();
+const academicYearController = require('../controllers/academicYearController');
+
+/**
+ * Academic Year Routes
+ */
+
+// GET /api/academic-years - Get all academic years with pagination
+router.get('/', academicYearController.getAllAcademicYears);
+
+// GET /api/academic-years/current - Get current academic year
+router.get('/current', academicYearController.getCurrentAcademicYear);
+
+// GET /api/academic-years/datatypes/schema - Get academic year datatypes
+router.get('/datatypes/schema', academicYearController.getAcademicYearDatatypes);
+
+// GET /api/academic-years/:id - Get academic year by ID
+router.get('/:id', academicYearController.getAcademicYearById);
+
+// POST /api/academic-years - Create new academic year
+router.post('/', academicYearController.createAcademicYear);
+
+// PUT /api/academic-years/:id - Update academic year
+router.put('/:id', academicYearController.updateAcademicYear);
+
+// PUT /api/academic-years/:id/set-current - Set as current academic year
+router.put('/:id/set-current', academicYearController.setCurrentAcademicYear);
+
+// DELETE /api/academic-years/:id - Delete academic year
+router.delete('/:id', academicYearController.deleteAcademicYear);
+
+module.exports = router;
diff --git a/routes/attendanceRoutes.js b/routes/attendanceRoutes.js
new file mode 100644
index 0000000..aedf22f
--- /dev/null
+++ b/routes/attendanceRoutes.js
@@ -0,0 +1,27 @@
+const express = require('express');
+const router = express.Router();
+const attendanceController = require('../controllers/attendanceController');
+
+/**
+ * Attendance Routes
+ */
+
+// Get attendance logs with pagination
+router.get('/logs', attendanceController.getAttendanceLogs);
+
+// Create attendance log (QR scan)
+router.post('/logs', attendanceController.createAttendanceLog);
+
+// Get daily attendance summary
+router.get('/daily', attendanceController.getDailyAttendance);
+
+// Process daily attendance (aggregate from logs)
+router.post('/process', attendanceController.processAttendance);
+
+// Get attendance statistics
+router.get('/stats', attendanceController.getAttendanceStats);
+
+// Get attendance datatypes
+router.get('/datatypes/schema', attendanceController.getAttendanceDatatypes);
+
+module.exports = router;
diff --git a/routes/authRoutes.js b/routes/authRoutes.js
new file mode 100644
index 0000000..d1f94a7
--- /dev/null
+++ b/routes/authRoutes.js
@@ -0,0 +1,24 @@
+const express = require('express');
+const router = express.Router();
+const authController = require('../controllers/authController');
+
+/**
+ * Authentication Routes - /api/auth
+ */
+
+// Login
+router.post('/login', authController.login);
+
+// Register
+router.post('/register', authController.register);
+
+// Verify token
+router.post('/verify-token', authController.verifyToken);
+
+// Logout
+router.post('/logout', authController.logout);
+
+// Get current user
+router.get('/me', authController.me);
+
+module.exports = router;
diff --git a/routes/chapterRoutes.js b/routes/chapterRoutes.js
new file mode 100644
index 0000000..665547b
--- /dev/null
+++ b/routes/chapterRoutes.js
@@ -0,0 +1,28 @@
+const express = require('express');
+const router = express.Router();
+const chapterController = require('../controllers/chapterController');
+
+/**
+ * Chapter Routes
+ * Base path: /api/chapters
+ */
+
+// Get all chapters
+router.get('/', chapterController.getAllChapters);
+
+// Get chapter by ID
+router.get('/:id', chapterController.getChapterById);
+
+// Get lessons in a chapter
+router.get('/:id/lessons', chapterController.getLessonsByChapter);
+
+// Create new chapter
+router.post('/', chapterController.createChapter);
+
+// Update chapter
+router.put('/:id', chapterController.updateChapter);
+
+// Delete chapter
+router.delete('/:id', chapterController.deleteChapter);
+
+module.exports = router;
diff --git a/routes/classRoutes.js b/routes/classRoutes.js
new file mode 100644
index 0000000..5aeebcb
--- /dev/null
+++ b/routes/classRoutes.js
@@ -0,0 +1,33 @@
+const express = require('express');
+const router = express.Router();
+const classController = require('../controllers/classController');
+
+/**
+ * Class Routes
+ */
+
+// GET /api/classes - Get all classes with pagination
+router.get('/', classController.getAllClasses);
+
+// GET /api/classes/statistics - Get class statistics
+router.get('/statistics', classController.getClassStatistics);
+
+// GET /api/classes/datatypes/schema - Get class datatypes
+router.get('/datatypes/schema', classController.getClassDatatypes);
+
+// GET /api/classes/:id - Get class by ID
+router.get('/:id', classController.getClassById);
+
+// GET /api/classes/:id/students - Get students in a class
+router.get('/:id/students', classController.getStudentsByClass);
+
+// POST /api/classes - Create new class
+router.post('/', classController.createClass);
+
+// PUT /api/classes/:id - Update class
+router.put('/:id', classController.updateClass);
+
+// DELETE /api/classes/:id - Delete class
+router.delete('/:id', classController.deleteClass);
+
+module.exports = router;
diff --git a/routes/gameRoutes.js b/routes/gameRoutes.js
new file mode 100644
index 0000000..ceba2fe
--- /dev/null
+++ b/routes/gameRoutes.js
@@ -0,0 +1,34 @@
+const express = require('express');
+const router = express.Router();
+const gameController = require('../controllers/gameController');
+
+/**
+ * Game Routes
+ * Base path: /api/games
+ */
+
+// Get all games
+router.get('/', gameController.getAllGames);
+
+// Get game statistics
+router.get('/stats', gameController.getGameStats);
+
+// Get games by type
+router.get('/type/:type', gameController.getGamesByType);
+
+// Get game by ID
+router.get('/:id', gameController.getGameById);
+
+// Create new game
+router.post('/', gameController.createGame);
+
+// Update game
+router.put('/:id', gameController.updateGame);
+
+// Delete game
+router.delete('/:id', gameController.deleteGame);
+
+// Increment play count
+router.post('/:id/play', gameController.incrementPlayCount);
+
+module.exports = router;
diff --git a/routes/gradeRoutes.js b/routes/gradeRoutes.js
new file mode 100644
index 0000000..1513f68
--- /dev/null
+++ b/routes/gradeRoutes.js
@@ -0,0 +1,33 @@
+const express = require('express');
+const router = express.Router();
+const gradeController = require('../controllers/gradeController');
+
+/**
+ * Grade Routes
+ */
+
+// Get grade datatypes (must be before /:id route)
+router.get('/datatypes/schema', gradeController.getGradeDatatypes);
+
+// Get student grade summary (must be before /:id route)
+router.get('/summary/:student_id/:academic_year_id', gradeController.getStudentGradeSummary);
+
+// Calculate student GPA
+router.post('/calculate-gpa', gradeController.calculateGPA);
+
+// Get all grades with pagination
+router.get('/', gradeController.getAllGrades);
+
+// Get grade by ID
+router.get('/:id', gradeController.getGradeById);
+
+// Create new grade
+router.post('/', gradeController.createGrade);
+
+// Update grade
+router.put('/:id', gradeController.updateGrade);
+
+// Delete grade
+router.delete('/:id', gradeController.deleteGrade);
+
+module.exports = router;
diff --git a/routes/parentTaskRoutes.js b/routes/parentTaskRoutes.js
new file mode 100644
index 0000000..9c1c1e7
--- /dev/null
+++ b/routes/parentTaskRoutes.js
@@ -0,0 +1,30 @@
+const express = require('express');
+const router = express.Router();
+const parentTaskController = require('../controllers/parentTaskController');
+
+/**
+ * Parent Task Routes
+ */
+
+// Get task stats
+router.get('/stats', parentTaskController.getTaskStats);
+
+// Get parent's assigned tasks
+router.get('/parent/:parent_id', parentTaskController.getParentTasks);
+
+// Get student's tasks
+router.get('/student/:student_id', parentTaskController.getStudentTasks);
+
+// Parent assigns task to student
+router.post('/assign', parentTaskController.assignTask);
+
+// Complete task
+router.put('/:id/complete', parentTaskController.completeTask);
+
+// Update task status
+router.put('/:id/status', parentTaskController.updateTaskStatus);
+
+// Delete task
+router.delete('/:id', parentTaskController.deleteTask);
+
+module.exports = router;
diff --git a/routes/roomRoutes.js b/routes/roomRoutes.js
new file mode 100644
index 0000000..4395298
--- /dev/null
+++ b/routes/roomRoutes.js
@@ -0,0 +1,30 @@
+const express = require('express');
+const router = express.Router();
+const roomController = require('../controllers/roomController');
+
+/**
+ * Room Routes
+ */
+
+// Get room datatypes (must be before /:id route)
+router.get('/datatypes/schema', roomController.getRoomDatatypes);
+
+// Get rooms by school (must be before /:id route)
+router.get('/school/:school_id', roomController.getRoomsBySchool);
+
+// Get all rooms with pagination
+router.get('/', roomController.getAllRooms);
+
+// Get room by ID
+router.get('/:id', roomController.getRoomById);
+
+// Create new room
+router.post('/', roomController.createRoom);
+
+// Update room
+router.put('/:id', roomController.updateRoom);
+
+// Delete room
+router.delete('/:id', roomController.deleteRoom);
+
+module.exports = router;
diff --git a/routes/schoolRoutes.js b/routes/schoolRoutes.js
new file mode 100644
index 0000000..ca3c3e4
--- /dev/null
+++ b/routes/schoolRoutes.js
@@ -0,0 +1,73 @@
+const express = require('express');
+const router = express.Router();
+const schoolController = require('../controllers/schoolController');
+// const { authenticate, authorize } = require('../middleware/auth'); // Auth middleware
+
+/**
+ * School Routes
+ * Base path: /api/schools
+ */
+
+/**
+ * @route GET /api/schools/datatypes/schema
+ * @desc Get school data types
+ * @access Protected
+ */
+router.get('/datatypes/schema', schoolController.getSchoolDatatypes);
+
+/**
+ * @route GET /api/schools
+ * @desc Get all schools with pagination
+ * @query page, limit, type, city, is_active
+ * @access Public (or Protected based on requirements)
+ */
+router.get('/', schoolController.getAllSchools);
+
+/**
+ * @route GET /api/schools/:id
+ * @desc Get school by ID
+ * @access Public
+ */
+router.get('/:id', schoolController.getSchoolById);
+
+/**
+ * @route GET /api/schools/:id/stats
+ * @desc Get school statistics
+ * @access Protected
+ */
+router.get('/:id/stats', schoolController.getSchoolStats);
+
+/**
+ * @route POST /api/schools
+ * @desc Create new school
+ * @access Protected - Admin only
+ */
+router.post('/',
+ // authenticate,
+ // authorize(['system_admin', 'center_manager']),
+ schoolController.createSchool
+);
+
+/**
+ * @route PUT /api/schools/:id
+ * @desc Update school
+ * @access Protected - Admin only
+ */
+router.put('/:id',
+ // authenticate,
+ // authorize(['system_admin', 'center_manager']),
+ schoolController.updateSchool
+);
+
+/**
+ * @route DELETE /api/schools/:id
+ * @desc Delete (soft delete) school
+ * @access Protected - System Admin only
+ */
+router.delete('/:id',
+ // authenticate,
+ // authorize(['system_admin']),
+ schoolController.deleteSchool
+);
+
+module.exports = router;
diff --git a/routes/studentRoutes.js b/routes/studentRoutes.js
new file mode 100644
index 0000000..b8baaf7
--- /dev/null
+++ b/routes/studentRoutes.js
@@ -0,0 +1,27 @@
+const express = require('express');
+const router = express.Router();
+const studentController = require('../controllers/studentController');
+
+/**
+ * Student Routes
+ */
+
+// Get student datatypes (must be before /:id route)
+router.get('/datatypes/schema', studentController.getStudentDatatypes);
+
+// Get all students with pagination
+router.get('/', studentController.getAllStudents);
+
+// Get student by ID
+router.get('/:id', studentController.getStudentById);
+
+// Create new student
+router.post('/', studentController.createStudent);
+
+// Update student
+router.put('/:id', studentController.updateStudent);
+
+// Delete student (update status to dropped)
+router.delete('/:id', studentController.deleteStudent);
+
+module.exports = router;
diff --git a/routes/subjectRoutes.js b/routes/subjectRoutes.js
new file mode 100644
index 0000000..0f27a23
--- /dev/null
+++ b/routes/subjectRoutes.js
@@ -0,0 +1,36 @@
+const express = require('express');
+const router = express.Router();
+const subjectController = require('../controllers/subjectController');
+
+/**
+ * Subject Routes
+ */
+
+// GET /api/subjects - Get all subjects with pagination
+router.get('/', subjectController.getAllSubjects);
+
+// GET /api/subjects/active - Get all active subjects
+router.get('/active', subjectController.getActiveSubjects);
+
+// GET /api/subjects/datatypes/schema - Get subject datatypes
+router.get('/datatypes/schema', subjectController.getSubjectDatatypes);
+
+// GET /api/subjects/code/:code - Get subject by code
+router.get('/code/:code', subjectController.getSubjectByCode);
+
+// GET /api/subjects/:id - Get subject by ID
+router.get('/:id', subjectController.getSubjectById);
+
+// GET /api/subjects/:id/chapters - Get chapters by subject
+router.get('/:id/chapters', subjectController.getChaptersBySubject);
+
+// POST /api/subjects - Create new subject
+router.post('/', subjectController.createSubject);
+
+// PUT /api/subjects/:id - Update subject
+router.put('/:id', subjectController.updateSubject);
+
+// DELETE /api/subjects/:id - Delete subject
+router.delete('/:id', subjectController.deleteSubject);
+
+module.exports = router;
diff --git a/routes/subscriptionRoutes.js b/routes/subscriptionRoutes.js
new file mode 100644
index 0000000..778a83e
--- /dev/null
+++ b/routes/subscriptionRoutes.js
@@ -0,0 +1,24 @@
+const express = require('express');
+const router = express.Router();
+const subscriptionController = require('../controllers/subscriptionController');
+
+/**
+ * Subscription Routes
+ */
+
+// Get all subscription plans
+router.get('/plans', subscriptionController.getPlans);
+
+// Get subscription stats
+router.get('/stats', subscriptionController.getSubscriptionStats);
+
+// Get user subscription status
+router.get('/user/:user_id', subscriptionController.getUserSubscription);
+
+// Purchase subscription
+router.post('/purchase', subscriptionController.purchaseSubscription);
+
+// Cancel subscription
+router.post('/:id/cancel', subscriptionController.cancelSubscription);
+
+module.exports = router;
diff --git a/routes/teacherRoutes.js b/routes/teacherRoutes.js
new file mode 100644
index 0000000..c1e13d3
--- /dev/null
+++ b/routes/teacherRoutes.js
@@ -0,0 +1,30 @@
+const express = require('express');
+const router = express.Router();
+const teacherController = require('../controllers/teacherController');
+
+/**
+ * Teacher Routes
+ */
+
+// Get teacher datatypes (must be before /:id route)
+router.get('/datatypes/schema', teacherController.getTeacherDatatypes);
+
+// Get all teachers with pagination
+router.get('/', teacherController.getAllTeachers);
+
+// Get teacher by ID
+router.get('/:id', teacherController.getTeacherById);
+
+// Create new teacher
+router.post('/', teacherController.createTeacher);
+
+// Update teacher
+router.put('/:id', teacherController.updateTeacher);
+
+// Update teacher profile (separate endpoint for user self-update)
+router.put('/:id/profile', teacherController.updateTeacherProfile);
+
+// Delete teacher (update status to resigned)
+router.delete('/:id', teacherController.deleteTeacher);
+
+module.exports = router;
diff --git a/routes/trainingRoutes.js b/routes/trainingRoutes.js
new file mode 100644
index 0000000..81c608d
--- /dev/null
+++ b/routes/trainingRoutes.js
@@ -0,0 +1,27 @@
+const express = require('express');
+const router = express.Router();
+const trainingController = require('../controllers/trainingController');
+
+/**
+ * Training Routes
+ */
+
+// Get training stats
+router.get('/stats', trainingController.getTrainingStats);
+
+// Get staff training assignments
+router.get('/assignments/:staff_id', trainingController.getStaffAssignments);
+
+// Assign training to staff
+router.post('/assignments', trainingController.assignTraining);
+
+// Update assignment status
+router.put('/assignments/:id/status', trainingController.updateAssignmentStatus);
+
+// Get staff achievements/certificates
+router.get('/achievements/:staff_id', trainingController.getStaffAchievements);
+
+// Create achievement record
+router.post('/achievements', trainingController.createAchievement);
+
+module.exports = router;
diff --git a/routes/userRoutes.js b/routes/userRoutes.js
new file mode 100644
index 0000000..ac9ba21
--- /dev/null
+++ b/routes/userRoutes.js
@@ -0,0 +1,41 @@
+const express = require('express');
+const router = express.Router();
+const userController = require('../controllers/userController');
+
+/**
+ * User Routes
+ */
+
+// === Authentication Routes ===
+// Login
+router.post('/login', userController.login);
+
+// Register
+router.post('/register', userController.register);
+
+// Verify token
+router.post('/verify-token', userController.verifyToken);
+
+// Logout
+router.post('/logout', userController.logout);
+
+// === User Management Routes ===
+// Get user datatypes (must be before /:id route)
+router.get('/datatypes/schema', userController.getUserDatatypes);
+
+// Get all users with pagination
+router.get('/', userController.getAllUsers);
+
+// Get user by ID
+router.get('/:id', userController.getUserById);
+
+// Create new user
+router.post('/', userController.createUser);
+
+// Update user
+router.put('/:id', userController.updateUser);
+
+// Delete user (soft delete)
+router.delete('/:id', userController.deleteUser);
+
+module.exports = router;
diff --git a/scripts/add-senaai-center.js b/scripts/add-senaai-center.js
new file mode 100644
index 0000000..d198c33
--- /dev/null
+++ b/scripts/add-senaai-center.js
@@ -0,0 +1,79 @@
+const { sequelize } = require('../config/database');
+const School = require('../models/School');
+
+/**
+ * Script thêm trung tâm SenaAI vào database
+ * Chạy: node src/scripts/add-senaai-center.js
+ */
+
+async function addSenaAICenter() {
+ try {
+ console.log('🚀 Đang thêm trung tâm SenaAI...\n');
+
+ // Kết nối database
+ await sequelize.authenticate();
+ console.log('✅ Kết nối database thành công\n');
+
+ // Kiểm tra trung tâm đã tồn tại chưa
+ const existingCenter = await School.findOne({
+ where: { school_code: 'SENAAI' }
+ });
+
+ if (existingCenter) {
+ console.log('⚠️ Trung tâm SenaAI đã tồn tại!');
+ console.log(` Tên: ${existingCenter.school_name}`);
+ console.log(` Mã: ${existingCenter.school_code}`);
+ console.log(` Địa chỉ: ${existingCenter.address || 'N/A'}`);
+ console.log('\n');
+ } else {
+ // Tạo mới trung tâm SenaAI
+ const senaAI = await School.create({
+ school_code: 'SENAAI',
+ school_name: 'Trung tâm Giáo dục SenaAI',
+ school_type: 'primary',
+ address: 'Thành phố Hồ Chí Minh',
+ city: 'Thành phố Hồ Chí Minh',
+ district: 'Đang cập nhật',
+ phone: '',
+ config: {
+ type: 'education_center',
+ services: ['AI Education', 'Technology Training', 'School Management']
+ },
+ is_active: true,
+ });
+
+ console.log('✅ Đã tạo trung tâm SenaAI thành công!');
+ console.log(` ID: ${senaAI.id}`);
+ console.log(` Tên: ${senaAI.school_name}`);
+ console.log(` Mã: ${senaAI.school_code}`);
+ console.log('\n');
+ }
+
+ // Hiển thị tổng số trường
+ const totalSchools = await School.count();
+ console.log(`📊 Tổng số trường trong hệ thống: ${totalSchools}`);
+ console.log('\n✅ Hoàn thành!\n');
+
+ } catch (error) {
+ console.error('❌ Lỗi:', error);
+ throw error;
+ } finally {
+ await sequelize.close();
+ console.log('🔌 Đã đóng kết nối database');
+ }
+}
+
+// Chạy script
+if (require.main === module) {
+ addSenaAICenter()
+ .then(() => {
+ console.log('\n🎉 Script hoàn thành thành công!');
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('\n💥 Script thất bại:', error);
+ process.exit(1);
+ });
+}
+
+module.exports = { addSenaAICenter };
diff --git a/scripts/clear-teacher-cache.js b/scripts/clear-teacher-cache.js
new file mode 100644
index 0000000..fe74354
--- /dev/null
+++ b/scripts/clear-teacher-cache.js
@@ -0,0 +1,50 @@
+/**
+ * Script: Clear Teacher Cache
+ * Xóa tất cả cache liên quan đến teachers
+ */
+
+require('dotenv').config();
+const { redisClient } = require('../config/redis');
+
+async function clearTeacherCache() {
+ console.log('🧹 Clearing teacher cache...\n');
+
+ try {
+ // Redis client is already connected via config
+ console.log('✅ Using existing Redis connection\n');
+
+ // Delete all teacher-related cache patterns
+ const patterns = [
+ 'teachers:list:*',
+ 'teacher:*',
+ ];
+
+ let totalDeleted = 0;
+
+ for (const pattern of patterns) {
+ console.log(`Searching for: ${pattern}`);
+ const keys = await redisClient.keys(pattern);
+
+ if (keys.length > 0) {
+ console.log(` Found ${keys.length} keys`);
+ await redisClient.del(...keys);
+ totalDeleted += keys.length;
+ console.log(` ✅ Deleted ${keys.length} keys\n`);
+ } else {
+ console.log(` No keys found\n`);
+ }
+ }
+
+ console.log('='.repeat(60));
+ console.log(`✅ Cache cleared! Total deleted: ${totalDeleted} keys`);
+ console.log('='.repeat(60));
+
+ process.exit(0);
+
+ } catch (error) {
+ console.error('❌ Error:', error.message);
+ process.exit(1);
+ }
+}
+
+clearTeacherCache();
diff --git a/scripts/create-test-user.js b/scripts/create-test-user.js
new file mode 100644
index 0000000..182c071
--- /dev/null
+++ b/scripts/create-test-user.js
@@ -0,0 +1,111 @@
+const { UsersAuth, UserProfile } = require('../models');
+const { initializeDatabase } = require('../config/database');
+const { setupRelationships } = require('../models');
+const bcrypt = require('bcrypt');
+const crypto = require('crypto');
+
+/**
+ * Script tạo user test để thử nghiệm login
+ */
+async function createTestUser() {
+ try {
+ console.log('🚀 Bắt đầu tạo user test...\n');
+
+ // Khởi tạo database
+ await initializeDatabase();
+ setupRelationships();
+
+ // Test credentials
+ const testUsers = [
+ {
+ username: 'admin',
+ email: 'admin@senaai.tech',
+ password: 'admin123',
+ full_name: 'Administrator',
+ phone: '0900000001',
+ },
+ {
+ username: 'teacher1',
+ email: 'teacher1@senaai.tech',
+ password: 'teacher123',
+ full_name: 'Giáo viên Test',
+ phone: '0900000002',
+ },
+ {
+ username: 'student1',
+ email: 'student1@senaai.tech',
+ password: 'student123',
+ full_name: 'Học sinh Test',
+ phone: '0900000003',
+ },
+ ];
+
+ for (const userData of testUsers) {
+ console.log(`📝 Đang tạo user: ${userData.username}...`);
+
+ // Kiểm tra user đã tồn tại chưa
+ const existingUser = await UsersAuth.findOne({
+ where: {
+ [require('sequelize').Op.or]: [
+ { username: userData.username },
+ { email: userData.email },
+ ],
+ },
+ });
+
+ if (existingUser) {
+ console.log(` ⚠️ User ${userData.username} đã tồn tại, bỏ qua.\n`);
+ continue;
+ }
+
+ // Hash password
+ const salt = crypto.randomBytes(16).toString('hex');
+ const passwordHash = await bcrypt.hash(userData.password + salt, 10);
+
+ // Tạo user
+ const newUser = await UsersAuth.create({
+ username: userData.username,
+ email: userData.email,
+ password_hash: passwordHash,
+ salt,
+ qr_secret: crypto.randomBytes(32).toString('hex'),
+ is_active: true,
+ });
+
+ // Tạo profile
+ await UserProfile.create({
+ user_id: newUser.id,
+ full_name: userData.full_name,
+ phone: userData.phone,
+ });
+
+ console.log(` ✅ Đã tạo user: ${userData.username}`);
+ console.log(` Email: ${userData.email}`);
+ console.log(` Password: ${userData.password}\n`);
+ }
+
+ console.log('✅ Hoàn tất tạo test users!\n');
+ console.log('═══════════════════════════════════════════════════');
+ console.log('📋 THÔNG TIN ĐĂNG NHẬP TEST:');
+ console.log('═══════════════════════════════════════════════════');
+ testUsers.forEach(user => {
+ console.log(`\n👤 ${user.full_name}`);
+ console.log(` Username: ${user.username}`);
+ console.log(` Email: ${user.email}`);
+ console.log(` Password: ${user.password}`);
+ });
+ console.log('\n═══════════════════════════════════════════════════');
+ console.log('\n🌐 Mở trình duyệt và truy cập:');
+ console.log(' http://localhost:4000/login.html');
+ console.log('\n💡 Sử dụng thông tin trên để đăng nhập!\n');
+
+ process.exit(0);
+ } catch (error) {
+ console.error('❌ Lỗi:', error.message);
+ console.error(error);
+ process.exit(1);
+ }
+}
+
+// Run script
+createTestUser();
diff --git a/scripts/drop-grade-tables.js b/scripts/drop-grade-tables.js
new file mode 100644
index 0000000..6cbf5a4
--- /dev/null
+++ b/scripts/drop-grade-tables.js
@@ -0,0 +1,22 @@
+const { sequelize } = require('../config/database');
+
+async function dropTables() {
+ try {
+ console.log('🔄 Dropping old grade tables...');
+
+ await sequelize.query('DROP TABLE IF EXISTS grade_history');
+ await sequelize.query('DROP TABLE IF EXISTS grade_summaries');
+ await sequelize.query('DROP TABLE IF EXISTS grades');
+ await sequelize.query('DROP TABLE IF EXISTS grade_items');
+ await sequelize.query('DROP TABLE IF EXISTS grade_categories');
+ await sequelize.query('DROP TABLE IF EXISTS leave_requests');
+
+ console.log('✅ Tables dropped successfully');
+ process.exit(0);
+ } catch (error) {
+ console.error('❌ Error dropping tables:', error.message);
+ process.exit(1);
+ }
+}
+
+dropTables();
diff --git a/scripts/dump-and-sync-teachers.js b/scripts/dump-and-sync-teachers.js
new file mode 100644
index 0000000..14ae41d
--- /dev/null
+++ b/scripts/dump-and-sync-teachers.js
@@ -0,0 +1,267 @@
+/**
+ * Script: Dump và Sync All Teachers
+ * Mục đích: Kiểm tra và tạo user_profile cho TẤT CẢ teachers trong hệ thống
+ *
+ * Usage:
+ * node src/scripts/dump-and-sync-teachers.js
+ * node src/scripts/dump-and-sync-teachers.js --auto-fix
+ */
+
+require('dotenv').config();
+const { sequelize, TeacherDetail, UserProfile, UsersAuth } = require('../models');
+const teacherProfileService = require('../services/teacherProfileService');
+
+async function dumpAllTeachers() {
+ console.log('📊 Dumping All Teachers Information...\n');
+ console.log('='.repeat(80));
+
+ try {
+ // Get all teachers (without join to avoid association issues)
+ const teachers = await TeacherDetail.findAll({
+ order: [['created_at', 'ASC']],
+ });
+
+ console.log(`\n📈 Total Teachers: ${teachers.length}\n`);
+
+ const withProfile = [];
+ const withoutProfile = [];
+ const invalidUserId = [];
+
+ for (const teacher of teachers) {
+ const teacherInfo = {
+ id: teacher.id,
+ user_id: teacher.user_id,
+ teacher_code: teacher.teacher_code,
+ teacher_type: teacher.teacher_type,
+ status: teacher.status,
+ created_at: teacher.created_at,
+ };
+
+ // Check if user_id exists in users_auth
+ const userAuth = await UsersAuth.findByPk(teacher.user_id, {
+ attributes: ['id', 'username', 'email', 'is_active'],
+ });
+
+ if (!userAuth) {
+ invalidUserId.push({
+ ...teacherInfo,
+ issue: 'user_id not found in users_auth',
+ });
+ continue;
+ }
+
+ teacherInfo.username = userAuth.username;
+ teacherInfo.email = userAuth.email;
+ teacherInfo.is_active = userAuth.is_active;
+
+ // Check if profile exists
+ const userProfile = await UserProfile.findOne({
+ where: { user_id: teacher.user_id },
+ });
+
+ if (userProfile) {
+ teacherInfo.profile = {
+ id: userProfile.id,
+ full_name: userProfile.full_name,
+ phone: userProfile.phone,
+ needs_update: userProfile.etc?.needs_profile_update,
+ };
+ withProfile.push(teacherInfo);
+ } else {
+ withoutProfile.push(teacherInfo);
+ }
+ }
+
+ // Display results
+ console.log('✅ Teachers WITH Profile:', withProfile.length);
+ if (withProfile.length > 0) {
+ console.log('\nSample (first 5):');
+ withProfile.slice(0, 5).forEach((t, i) => {
+ console.log(` ${i + 1}. ${t.teacher_code} (${t.teacher_type}) - ${t.username}`);
+ console.log(` Profile: ${t.profile.full_name} | Phone: ${t.profile.phone || 'N/A'}`);
+ });
+ if (withProfile.length > 5) {
+ console.log(` ... and ${withProfile.length - 5} more\n`);
+ }
+ }
+
+ console.log('\n❌ Teachers WITHOUT Profile:', withoutProfile.length);
+ if (withoutProfile.length > 0) {
+ console.log('\nList:');
+ withoutProfile.forEach((t, i) => {
+ console.log(` ${i + 1}. ${t.teacher_code} (${t.teacher_type}) - ${t.username} - user_id: ${t.user_id}`);
+ });
+ }
+
+ console.log('\n⚠️ Teachers with Invalid user_id:', invalidUserId.length);
+ if (invalidUserId.length > 0) {
+ console.log('\nList:');
+ invalidUserId.forEach((t, i) => {
+ console.log(` ${i + 1}. ${t.teacher_code} - ${t.issue} - user_id: ${t.user_id}`);
+ });
+ }
+
+ console.log('\n' + '='.repeat(80));
+
+ return {
+ total: teachers.length,
+ withProfile: withProfile.length,
+ withoutProfile: withoutProfile.length,
+ invalidUserId: invalidUserId.length,
+ teachers: {
+ withProfile,
+ withoutProfile,
+ invalidUserId,
+ }
+ };
+
+ } catch (error) {
+ console.error('❌ Dump failed:', error.message);
+ throw error;
+ }
+}
+
+async function autoFixProfiles(teachersData) {
+ console.log('\n🔧 Starting Auto-Fix for Teachers without Profile...\n');
+
+ const { withoutProfile } = teachersData.teachers;
+
+ if (withoutProfile.length === 0) {
+ console.log('✅ No teachers need fixing. All have profiles!\n');
+ return { success: 0, failed: 0 };
+ }
+
+ console.log(`📋 Processing ${withoutProfile.length} teachers...\n`);
+
+ const results = {
+ success: [],
+ failed: [],
+ };
+
+ for (const teacher of withoutProfile) {
+ try {
+ console.log(`Processing: ${teacher.teacher_code} (${teacher.username})...`);
+
+ // Create profile using service
+ const userProfile = await UserProfile.create({
+ user_id: teacher.user_id,
+ full_name: teacherProfileService._generateFullNameFromCode(teacher.teacher_code),
+ first_name: '',
+ last_name: '',
+ phone: '',
+ date_of_birth: null,
+ gender: null,
+ address: '',
+ school_id: null,
+ city: '',
+ district: '',
+ avatar_url: null,
+ etc: {
+ teacher_code: teacher.teacher_code,
+ teacher_type: teacher.teacher_type,
+ username: teacher.username,
+ synced_from: 'dump-and-sync-script',
+ needs_profile_update: true,
+ synced_at: new Date().toISOString(),
+ }
+ });
+
+ results.success.push({
+ teacher_code: teacher.teacher_code,
+ username: teacher.username,
+ user_id: teacher.user_id,
+ profile_id: userProfile.id,
+ full_name: userProfile.full_name,
+ });
+
+ console.log(` ✅ Created profile: ${userProfile.full_name}\n`);
+
+ } catch (error) {
+ results.failed.push({
+ teacher_code: teacher.teacher_code,
+ username: teacher.username,
+ error: error.message,
+ });
+
+ console.log(` ❌ Failed: ${error.message}\n`);
+ }
+ }
+
+ // Summary
+ console.log('\n' + '='.repeat(80));
+ console.log('📊 AUTO-FIX SUMMARY');
+ console.log('='.repeat(80));
+ console.log(`✅ Success: ${results.success.length}`);
+ console.log(`❌ Failed: ${results.failed.length}`);
+
+ if (results.success.length > 0) {
+ console.log('\n✅ Successfully Created Profiles:');
+ results.success.forEach((item, i) => {
+ console.log(` ${i + 1}. ${item.teacher_code} → ${item.full_name}`);
+ console.log(` user_id: ${item.user_id} | profile_id: ${item.profile_id}`);
+ });
+ }
+
+ if (results.failed.length > 0) {
+ console.log('\n❌ Failed:');
+ results.failed.forEach((item, i) => {
+ console.log(` ${i + 1}. ${item.teacher_code} - ${item.error}`);
+ });
+ }
+
+ console.log('\n' + '='.repeat(80));
+
+ return {
+ success: results.success.length,
+ failed: results.failed.length,
+ details: results,
+ };
+}
+
+async function main() {
+ console.log('\n🚀 Teacher Profile Dump & Sync Tool\n');
+
+ try {
+ // Parse arguments
+ const args = process.argv.slice(2);
+ const autoFix = args.includes('--auto-fix');
+
+ // Connect to database
+ await sequelize.authenticate();
+ console.log('✅ Database connected\n');
+
+ // Step 1: Dump all teachers
+ const dumpResults = await dumpAllTeachers();
+
+ // Step 2: Ask for auto-fix if not specified
+ if (!autoFix && dumpResults.withoutProfile > 0) {
+ console.log('\n⚠️ Found teachers without profile!');
+ console.log('💡 Run with --auto-fix to create profiles automatically:');
+ console.log(' node src/scripts/dump-and-sync-teachers.js --auto-fix\n');
+ process.exit(0);
+ }
+
+ // Step 3: Auto-fix if requested
+ if (autoFix && dumpResults.withoutProfile > 0) {
+ const fixResults = await autoFixProfiles(dumpResults);
+
+ console.log('\n🎉 COMPLETED!');
+ console.log(` Total: ${dumpResults.total} teachers`);
+ console.log(` Fixed: ${fixResults.success} profiles`);
+ console.log(` Already had: ${dumpResults.withProfile} profiles`);
+ console.log(` Failed: ${fixResults.failed} profiles\n`);
+ } else if (autoFix) {
+ console.log('\n✅ All teachers already have profiles! Nothing to fix.\n');
+ }
+
+ process.exit(0);
+
+ } catch (error) {
+ console.error('\n❌ Error:', error.message);
+ console.error(error.stack);
+ process.exit(1);
+ }
+}
+
+// Run
+main();
diff --git a/scripts/export-teachers-info.js b/scripts/export-teachers-info.js
new file mode 100644
index 0000000..8b6584f
--- /dev/null
+++ b/scripts/export-teachers-info.js
@@ -0,0 +1,193 @@
+/**
+ * Script: Export Teacher Profiles to JSON
+ * Xuất chi tiết thông tin tất cả teachers ra file JSON
+ *
+ * Usage:
+ * node src/scripts/export-teachers-info.js
+ * node src/scripts/export-teachers-info.js --output=teachers.json
+ */
+
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const { sequelize, TeacherDetail, UserProfile, UsersAuth } = require('../models');
+
+async function exportTeachersInfo() {
+ console.log('📤 Exporting All Teachers Information...\n');
+
+ try {
+ await sequelize.authenticate();
+ console.log('✅ Database connected\n');
+
+ // Get all teachers
+ const teachers = await TeacherDetail.findAll({
+ order: [['created_at', 'ASC']],
+ });
+
+ console.log(`📊 Found ${teachers.length} teachers\n`);
+ console.log('Processing...\n');
+
+ const exportData = [];
+
+ for (const teacher of teachers) {
+ // Get auth info
+ const userAuth = await UsersAuth.findByPk(teacher.user_id, {
+ attributes: ['id', 'username', 'email', 'is_active', 'last_login', 'login_count', 'qr_version'],
+ });
+
+ // Get profile info
+ const userProfile = await UserProfile.findOne({
+ where: { user_id: teacher.user_id },
+ });
+
+ const teacherData = {
+ // Teacher Details
+ teacher_id: teacher.id,
+ teacher_code: teacher.teacher_code,
+ teacher_type: teacher.teacher_type,
+ status: teacher.status,
+ qualification: teacher.qualification,
+ specialization: teacher.specialization,
+ hire_date: teacher.hire_date,
+ training_hours: teacher.training_hours,
+ last_training_date: teacher.last_training_date,
+ training_score_avg: teacher.training_score_avg,
+ skill_tags: teacher.skill_tags,
+ certifications: teacher.certifications,
+ created_at: teacher.created_at,
+ updated_at: teacher.updated_at,
+
+ // Auth Info
+ user_id: teacher.user_id,
+ username: userAuth?.username,
+ email: userAuth?.email,
+ is_active: userAuth?.is_active,
+ last_login: userAuth?.last_login,
+ login_count: userAuth?.login_count,
+ qr_version: userAuth?.qr_version,
+
+ // Profile Info
+ has_profile: !!userProfile,
+ profile: userProfile ? {
+ profile_id: userProfile.id,
+ full_name: userProfile.full_name,
+ first_name: userProfile.first_name,
+ last_name: userProfile.last_name,
+ phone: userProfile.phone,
+ date_of_birth: userProfile.date_of_birth,
+ gender: userProfile.gender,
+ address: userProfile.address,
+ school_id: userProfile.school_id,
+ city: userProfile.city,
+ district: userProfile.district,
+ avatar_url: userProfile.avatar_url,
+ etc: userProfile.etc,
+ created_at: userProfile.created_at,
+ updated_at: userProfile.updated_at,
+ } : null,
+ };
+
+ exportData.push(teacherData);
+ }
+
+ // Summary statistics
+ const stats = {
+ total_teachers: exportData.length,
+ with_profile: exportData.filter(t => t.has_profile).length,
+ without_profile: exportData.filter(t => !t.has_profile).length,
+ active: exportData.filter(t => t.is_active).length,
+ inactive: exportData.filter(t => !t.is_active).length,
+ by_type: {
+ foreign: exportData.filter(t => t.teacher_type === 'foreign').length,
+ homeroom: exportData.filter(t => t.teacher_type === 'homeroom').length,
+ subject: exportData.filter(t => t.teacher_type === 'subject').length,
+ assistant: exportData.filter(t => t.teacher_type === 'assistant').length,
+ },
+ by_status: {
+ active: exportData.filter(t => t.status === 'active').length,
+ on_leave: exportData.filter(t => t.status === 'on_leave').length,
+ resigned: exportData.filter(t => t.status === 'resigned').length,
+ },
+ };
+
+ // Parse output filename
+ const args = process.argv.slice(2);
+ const outputArg = args.find(arg => arg.startsWith('--output='));
+ const outputFileName = outputArg ? outputArg.split('=')[1] : 'teachers-export.json';
+ const outputPath = path.join(process.cwd(), '..', 'data', outputFileName);
+
+ // Prepare export object
+ const exportObject = {
+ exported_at: new Date().toISOString(),
+ statistics: stats,
+ teachers: exportData,
+ };
+
+ // Write to file
+ fs.writeFileSync(outputPath, JSON.stringify(exportObject, null, 2), 'utf8');
+
+ console.log('✅ Export completed!\n');
+ console.log('📊 Statistics:');
+ console.log('='.repeat(60));
+ console.log(`Total Teachers: ${stats.total_teachers}`);
+ console.log(` ✅ With Profile: ${stats.with_profile}`);
+ console.log(` ❌ Without Profile: ${stats.without_profile}`);
+ console.log(` 🟢 Active: ${stats.active}`);
+ console.log(` 🔴 Inactive: ${stats.inactive}`);
+ console.log('\nBy Teacher Type:');
+ console.log(` Foreign: ${stats.by_type.foreign}`);
+ console.log(` Homeroom: ${stats.by_type.homeroom}`);
+ console.log(` Subject: ${stats.by_type.subject}`);
+ console.log(` Assistant: ${stats.by_type.assistant}`);
+ console.log('\nBy Status:');
+ console.log(` Active: ${stats.by_status.active}`);
+ console.log(` On Leave: ${stats.by_status.on_leave}`);
+ console.log(` Resigned: ${stats.by_status.resigned}`);
+ console.log('='.repeat(60));
+ console.log(`\n📁 File saved: ${outputPath}`);
+ console.log(` Size: ${(fs.statSync(outputPath).size / 1024).toFixed(2)} KB\n`);
+
+ // Show sample data
+ console.log('📋 Sample Teacher Data (first 3):\n');
+ exportData.slice(0, 3).forEach((t, i) => {
+ console.log(`${i + 1}. ${t.teacher_code} (${t.teacher_type})`);
+ console.log(` Username: ${t.username}`);
+ console.log(` Email: ${t.email}`);
+ console.log(` Full Name: ${t.profile?.full_name || 'N/A'}`);
+ console.log(` Phone: ${t.profile?.phone || 'N/A'}`);
+ console.log(` Status: ${t.status} | Active: ${t.is_active}`);
+ console.log('');
+ });
+
+ return {
+ outputPath,
+ stats,
+ };
+
+ } catch (error) {
+ console.error('❌ Export failed:', error.message);
+ throw error;
+ }
+}
+
+async function main() {
+ console.log('\n🚀 Teacher Profile Export Tool\n');
+
+ try {
+ const result = await exportTeachersInfo();
+
+ console.log('🎉 Done! You can now view the exported file:\n');
+ console.log(` cat ${result.outputPath}`);
+ console.log(` or open it in a text editor/VS Code\n`);
+
+ process.exit(0);
+
+ } catch (error) {
+ console.error('\n❌ Error:', error.message);
+ console.error(error.stack);
+ process.exit(1);
+ }
+}
+
+// Run
+main();
diff --git a/scripts/generate-models.js b/scripts/generate-models.js
new file mode 100644
index 0000000..6965cac
--- /dev/null
+++ b/scripts/generate-models.js
@@ -0,0 +1,305 @@
+/**
+ * Tạo nhanh các models còn lại
+ * Chạy script này để generate các file model
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const models = [
+ {
+ name: 'Permission',
+ tableName: 'permissions',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ permission_code: { type: DataTypes.STRING(50), unique: true, allowNull: false },
+ permission_name: { type: DataTypes.STRING(100), allowNull: false },
+ permission_description: { type: DataTypes.TEXT },
+ resource: { type: DataTypes.STRING(50), comment: 'Tài nguyên (students, grades, etc.)' },
+ action: { type: DataTypes.STRING(50), comment: 'Hành động (view, create, edit, delete)' },
+ is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
+`,
+ },
+ {
+ name: 'RolePermission',
+ tableName: 'role_permissions',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ role_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'roles', key: 'id' } },
+ permission_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'permissions', key: 'id' } },
+`,
+ },
+ {
+ name: 'UserAssignment',
+ tableName: 'user_assignments',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ user_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'users_auth', key: 'id' } },
+ role_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'roles', key: 'id' } },
+ school_id: { type: DataTypes.INTEGER, references: { model: 'schools', key: 'id' } },
+ class_id: { type: DataTypes.INTEGER, references: { model: 'classes', key: 'id' } },
+ is_primary: { type: DataTypes.BOOLEAN, defaultValue: false, comment: 'Vai trò chính' },
+ valid_from: { type: DataTypes.DATE },
+ valid_until: { type: DataTypes.DATE },
+ is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
+`,
+ },
+ {
+ name: 'StudentDetail',
+ tableName: 'student_details',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ user_id: { type: DataTypes.INTEGER, unique: true, allowNull: false, references: { model: 'user_profiles', key: 'user_id' } },
+ student_code: { type: DataTypes.STRING(20), unique: true, allowNull: false },
+ enrollment_date: { type: DataTypes.DATE },
+ current_class_id: { type: DataTypes.INTEGER, references: { model: 'classes', key: 'id' } },
+ status: { type: DataTypes.ENUM('active', 'on_leave', 'graduated', 'dropped'), defaultValue: 'active' },
+`,
+ },
+ {
+ name: 'TeacherDetail',
+ tableName: 'teacher_details',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ user_id: { type: DataTypes.INTEGER, unique: true, allowNull: false, references: { model: 'user_profiles', key: 'user_id' } },
+ teacher_code: { type: DataTypes.STRING(20), unique: true, allowNull: false },
+ teacher_type: { type: DataTypes.ENUM('foreign', 'assistant', 'homeroom', 'specialist'), allowNull: false },
+ qualification: { type: DataTypes.STRING(200) },
+ specialization: { type: DataTypes.STRING(200) },
+ hire_date: { type: DataTypes.DATE },
+ status: { type: DataTypes.ENUM('active', 'on_leave', 'resigned'), defaultValue: 'active' },
+`,
+ },
+ {
+ name: 'ParentStudentMap',
+ tableName: 'parent_student_map',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ parent_id: { type: DataTypes.INTEGER, allowNull: false },
+ student_id: { type: DataTypes.INTEGER, allowNull: false },
+ relationship: { type: DataTypes.ENUM('father', 'mother', 'guardian', 'other'), allowNull: false },
+ is_primary_contact: { type: DataTypes.BOOLEAN, defaultValue: false },
+`,
+ },
+ {
+ name: 'StaffContract',
+ tableName: 'staff_contracts',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ teacher_id: { type: DataTypes.INTEGER, allowNull: false },
+ school_id: { type: DataTypes.INTEGER, allowNull: false },
+ contract_type: { type: DataTypes.ENUM('full_time', 'part_time', 'contract'), allowNull: false },
+ start_date: { type: DataTypes.DATE, allowNull: false },
+ end_date: { type: DataTypes.DATE },
+ salary: { type: DataTypes.DECIMAL(12, 2) },
+ status: { type: DataTypes.ENUM('active', 'expired', 'terminated'), defaultValue: 'active' },
+`,
+ },
+ {
+ name: 'AcademicYear',
+ tableName: 'academic_years',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ year_name: { type: DataTypes.STRING(50), allowNull: false },
+ start_date: { type: DataTypes.DATE, allowNull: false },
+ end_date: { type: DataTypes.DATE, allowNull: false },
+ semester_count: { type: DataTypes.INTEGER, defaultValue: 2 },
+ is_current: { type: DataTypes.BOOLEAN, defaultValue: false },
+`,
+ },
+ {
+ name: 'Subject',
+ tableName: 'subjects',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ subject_code: { type: DataTypes.STRING(20), unique: true, allowNull: false },
+ subject_name: { type: DataTypes.STRING(100), allowNull: false },
+ subject_name_en: { type: DataTypes.STRING(100) },
+ description: { type: DataTypes.TEXT },
+ is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
+`,
+ },
+ {
+ name: 'Class',
+ tableName: 'classes',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ class_code: { type: DataTypes.STRING(20), unique: true, allowNull: false },
+ class_name: { type: DataTypes.STRING(100), allowNull: false },
+ school_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'schools', key: 'id' } },
+ academic_year_id: { type: DataTypes.INTEGER, allowNull: false },
+ grade_level: { type: DataTypes.INTEGER },
+ homeroom_teacher_id: { type: DataTypes.INTEGER },
+ max_students: { type: DataTypes.INTEGER, defaultValue: 30 },
+ current_students: { type: DataTypes.INTEGER, defaultValue: 0 },
+`,
+ },
+ {
+ name: 'ClassSchedule',
+ tableName: 'class_schedules',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ class_id: { type: DataTypes.INTEGER, allowNull: false },
+ subject_id: { type: DataTypes.INTEGER, allowNull: false },
+ teacher_id: { type: DataTypes.INTEGER },
+ room_id: { type: DataTypes.INTEGER },
+ day_of_week: { type: DataTypes.INTEGER, comment: '1=Monday, 7=Sunday' },
+ period: { type: DataTypes.INTEGER, comment: 'Tiết học' },
+ start_time: { type: DataTypes.TIME },
+ end_time: { type: DataTypes.TIME },
+`,
+ },
+ {
+ name: 'Room',
+ tableName: 'rooms',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ room_code: { type: DataTypes.STRING(20), allowNull: false },
+ room_name: { type: DataTypes.STRING(100), allowNull: false },
+ school_id: { type: DataTypes.INTEGER, allowNull: false },
+ room_type: { type: DataTypes.ENUM('classroom', 'lab', 'library', 'gym', 'other') },
+ capacity: { type: DataTypes.INTEGER },
+ floor: { type: DataTypes.INTEGER },
+`,
+ },
+ {
+ name: 'AttendanceDaily',
+ tableName: 'attendance_daily',
+ fields: `
+ id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
+ user_id: { type: DataTypes.INTEGER, allowNull: false },
+ school_id: { type: DataTypes.INTEGER, allowNull: false },
+ class_id: { type: DataTypes.INTEGER },
+ attendance_date: { type: DataTypes.DATE, allowNull: false },
+ status: { type: DataTypes.ENUM('present', 'absent', 'late', 'excused'), allowNull: false },
+ check_in_time: { type: DataTypes.TIME },
+ check_out_time: { type: DataTypes.TIME },
+ notes: { type: DataTypes.TEXT },
+`,
+ },
+ {
+ name: 'LeaveRequest',
+ tableName: 'leave_requests',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ student_id: { type: DataTypes.INTEGER, allowNull: false },
+ requested_by: { type: DataTypes.INTEGER, allowNull: false, comment: 'Parent user_id' },
+ leave_from: { type: DataTypes.DATE, allowNull: false },
+ leave_to: { type: DataTypes.DATE, allowNull: false },
+ reason: { type: DataTypes.TEXT, allowNull: false },
+ status: { type: DataTypes.ENUM('pending', 'approved', 'rejected'), defaultValue: 'pending' },
+ approved_by: { type: DataTypes.INTEGER },
+ approved_at: { type: DataTypes.DATE },
+`,
+ },
+ {
+ name: 'GradeCategory',
+ tableName: 'grade_categories',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ category_code: { type: DataTypes.STRING(20), allowNull: false },
+ category_name: { type: DataTypes.STRING(100), allowNull: false },
+ subject_id: { type: DataTypes.INTEGER },
+ weight: { type: DataTypes.DECIMAL(5, 2), comment: 'Trọng số %' },
+ description: { type: DataTypes.TEXT },
+`,
+ },
+ {
+ name: 'GradeItem',
+ tableName: 'grade_items',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ item_name: { type: DataTypes.STRING(200), allowNull: false },
+ category_id: { type: DataTypes.INTEGER, allowNull: false },
+ class_id: { type: DataTypes.INTEGER, allowNull: false },
+ max_score: { type: DataTypes.DECIMAL(5, 2), defaultValue: 100 },
+ exam_date: { type: DataTypes.DATE },
+ description: { type: DataTypes.TEXT },
+`,
+ },
+ {
+ name: 'GradeSummary',
+ tableName: 'grade_summaries',
+ fields: `
+ id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
+ student_id: { type: DataTypes.INTEGER, allowNull: false },
+ subject_id: { type: DataTypes.INTEGER, allowNull: false },
+ academic_year_id: { type: DataTypes.INTEGER, allowNull: false },
+ semester: { type: DataTypes.INTEGER },
+ average_score: { type: DataTypes.DECIMAL(5, 2) },
+ final_grade: { type: DataTypes.STRING(5) },
+`,
+ },
+ {
+ name: 'GradeHistory',
+ tableName: 'grade_history',
+ fields: `
+ id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
+ grade_id: { type: DataTypes.BIGINT, allowNull: false },
+ changed_by: { type: DataTypes.INTEGER, allowNull: false },
+ old_score: { type: DataTypes.DECIMAL(5, 2) },
+ new_score: { type: DataTypes.DECIMAL(5, 2) },
+ change_reason: { type: DataTypes.TEXT },
+ changed_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+`,
+ },
+ {
+ name: 'Notification',
+ tableName: 'notifications',
+ fields: `
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ title: { type: DataTypes.STRING(200), allowNull: false },
+ content: { type: DataTypes.TEXT, allowNull: false },
+ notification_type: { type: DataTypes.ENUM('system', 'school', 'class', 'personal'), allowNull: false },
+ priority: { type: DataTypes.ENUM('low', 'medium', 'high', 'urgent'), defaultValue: 'medium' },
+ target_audience: { type: DataTypes.JSONB, comment: 'Schools, classes, roles to send' },
+ scheduled_at: { type: DataTypes.DATE },
+ expires_at: { type: DataTypes.DATE },
+`,
+ },
+ {
+ name: 'NotificationLog',
+ tableName: 'notification_logs',
+ fields: `
+ id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
+ notification_id: { type: DataTypes.INTEGER, allowNull: false },
+ user_id: { type: DataTypes.INTEGER, allowNull: false },
+ sent_at: { type: DataTypes.DATE },
+ read_at: { type: DataTypes.DATE },
+ status: { type: DataTypes.ENUM('pending', 'sent', 'failed', 'read'), defaultValue: 'pending' },
+`,
+ },
+ {
+ name: 'Message',
+ tableName: 'messages',
+ fields: `
+ id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
+ sender_id: { type: DataTypes.INTEGER, allowNull: false },
+ recipient_id: { type: DataTypes.INTEGER, allowNull: false },
+ subject: { type: DataTypes.STRING(200) },
+ content: { type: DataTypes.TEXT, allowNull: false },
+ is_read: { type: DataTypes.BOOLEAN, defaultValue: false },
+ read_at: { type: DataTypes.DATE },
+ parent_message_id: { type: DataTypes.BIGINT, comment: 'For thread replies' },
+`,
+ },
+ {
+ name: 'AuditLog',
+ tableName: 'audit_logs_system',
+ fields: `
+ id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
+ user_id: { type: DataTypes.INTEGER, allowNull: false },
+ action: { type: DataTypes.STRING(100), allowNull: false },
+ resource_type: { type: DataTypes.STRING(50) },
+ resource_id: { type: DataTypes.INTEGER },
+ old_values: { type: DataTypes.JSONB },
+ new_values: { type: DataTypes.JSONB },
+ ip_address: { type: DataTypes.INET },
+ user_agent: { type: DataTypes.TEXT },
+ performed_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
+`,
+ },
+];
+
+console.log(`Tạo ${models.length} file models...`);
+console.log('Các models cần tạo:', models.map(m => m.name).join(', '));
diff --git a/scripts/import-apdinh-students.js b/scripts/import-apdinh-students.js
new file mode 100644
index 0000000..c2628bf
--- /dev/null
+++ b/scripts/import-apdinh-students.js
@@ -0,0 +1,315 @@
+const { sequelize } = require('../config/database');
+const UsersAuth = require('../models/UsersAuth');
+const UserProfile = require('../models/UserProfile');
+const StudentDetail = require('../models/StudentDetail');
+const School = require('../models/School');
+const Class = require('../models/Class');
+const bcrypt = require('bcrypt');
+const fs = require('fs');
+const path = require('path');
+
+/**
+ * Script import học sinh trường Tiểu học Ấp Đình từ apdinh.tsv
+ * Chạy: node src/scripts/import-apdinh-students.js
+ */
+
+// Hàm hash password
+async function hashPassword(password) {
+ const salt = await bcrypt.genSalt(10);
+ const hash = await bcrypt.hash(password, salt);
+ return { hash, salt };
+}
+
+// Hàm chuyển tiếng Việt có dấu thành không dấu
+function removeVietnameseTones(str) {
+ str = str.toLowerCase();
+ str = str.replace(/à|á|ạ|ả|ã|â|ầ|ấ|ậ|ẩ|ẫ|ă|ằ|ắ|ặ|ẳ|ẵ/g, 'a');
+ str = str.replace(/è|é|ẹ|ẻ|ẽ|ê|ề|ế|ệ|ể|ễ/g, 'e');
+ str = str.replace(/ì|í|ị|ỉ|ĩ/g, 'i');
+ str = str.replace(/ò|ó|ọ|ỏ|õ|ô|ồ|ố|ộ|ổ|ỗ|ơ|ờ|ớ|ợ|ở|ỡ/g, 'o');
+ str = str.replace(/ù|ú|ụ|ủ|ũ|ư|ừ|ứ|ự|ử|ữ/g, 'u');
+ str = str.replace(/ỳ|ý|ỵ|ỷ|ỹ/g, 'y');
+ str = str.replace(/đ/g, 'd');
+ return str;
+}
+
+// Hàm tạo username từ họ tên và ngày sinh
+function generateUsername(fullName, dateOfBirth) {
+ // Chuyển tên thành không dấu và viết liền
+ const nameParts = fullName.trim().split(/\s+/);
+ const nameSlug = nameParts.map(part => removeVietnameseTones(part)).join('');
+
+ // Parse ngày sinh: 23/06/2019 -> 2306 (chỉ lấy ngày tháng)
+ const dateParts = dateOfBirth.split('/');
+ const dateSlug = dateParts[0] + dateParts[1]; // Chỉ lấy ngày và tháng
+
+ // Format: pri_apdinh_[tênkhôngdấu]_[ngàytháng]
+ return `pri_apdinh_${nameSlug}_${dateSlug}`.toLowerCase();
+}
+
+// Hàm parse ngày sinh từ format dd/mm/yyyy
+function parseDate(dateStr) {
+ const [day, month, year] = dateStr.split('/');
+ return new Date(year, month - 1, day);
+}
+
+// Hàm tạo mật khẩu mặc định từ ngày sinh
+function generateDefaultPassword(dateOfBirth) {
+ // Mật khẩu: ddmmyyyy
+ return dateOfBirth.replace(/\//g, '');
+}
+
+async function importApdinhStudents() {
+ try {
+ console.log('🚀 Bắt đầu import học sinh Trường Tiểu học Ấp Đình...\n');
+
+ // 1. Đọc file apdinh.tsv
+ const filePath = path.join(__dirname, '../../data/apdinh.tsv');
+ const fileContent = fs.readFileSync(filePath, 'utf8');
+ const lines = fileContent.split('\n').filter(line => line.trim());
+
+ // Bỏ header
+ const dataLines = lines.slice(1);
+ console.log(`📚 Tìm thấy ${dataLines.length} học sinh trong file\n`);
+
+ // Kết nối database
+ await sequelize.authenticate();
+ console.log('✅ Kết nối database thành công\n');
+
+ // 2. Tìm trường Tiểu học Ấp Đình
+ console.log('🔍 Đang tìm trường Tiểu học Ấp Đình...');
+ const school = await School.findOne({
+ where: sequelize.where(
+ sequelize.fn('LOWER', sequelize.col('school_name')),
+ 'LIKE',
+ '%ấp đình%'
+ )
+ });
+
+ if (!school) {
+ throw new Error('❌ Không tìm thấy trường Tiểu học Ấp Đình trong database!');
+ }
+
+ console.log(`✅ Tìm thấy trường: ${school.school_name} (${school.school_code})\n`);
+
+ // 3. Lấy tất cả các lớp của trường Ấp Đình
+ console.log('🔍 Đang tải danh sách lớp...');
+ const classes = await Class.findAll({
+ where: { school_id: school.id }
+ });
+
+ if (classes.length === 0) {
+ throw new Error('❌ Không tìm thấy lớp nào của trường Ấp Đình! Vui lòng import classes trước.');
+ }
+
+ console.log(`✅ Tìm thấy ${classes.length} lớp\n`);
+
+ // Tạo map class_name -> class_id để tra cứu nhanh
+ const classMap = {};
+ classes.forEach(cls => {
+ // Lưu cả class_code và class_name
+ classMap[cls.class_code] = cls;
+ classMap[cls.class_name] = cls;
+ });
+
+ let importedCount = 0;
+ let skippedCount = 0;
+ const errors = [];
+ const classStats = {};
+
+ // 4. Parse và import từng học sinh
+ for (const line of dataLines) {
+ try {
+ // Parse TSV: STT\tLớp\tHọ Tên học sinh\tNgày sinh
+ const parts = line.split('\t');
+ if (parts.length < 4) {
+ console.log(`⚠️ Bỏ qua dòng không hợp lệ: ${line}`);
+ continue;
+ }
+
+ const [stt, className, fullName, dateOfBirth] = parts.map(p => p.trim());
+
+ // Bỏ qua nếu thiếu thông tin
+ if (!fullName || !dateOfBirth || !className) {
+ console.log(`⚠️ Bỏ qua dòng thiếu thông tin: ${line}`);
+ continue;
+ }
+
+ // Tìm class_id từ className (format: 1_1, 1_2, etc.)
+ const classKey = className.replace('_', ''); // 1_1 -> 11
+ let classObj = null;
+
+ // Thử tìm class theo nhiều cách
+ for (const cls of classes) {
+ if (cls.class_code.includes(className) ||
+ cls.class_name.includes(className) ||
+ cls.class_code.includes(classKey)) {
+ classObj = cls;
+ break;
+ }
+ }
+
+ if (!classObj) {
+ console.log(`⚠️ Không tìm thấy lớp ${className}, bỏ qua: ${fullName}`);
+ errors.push({
+ line,
+ error: `Không tìm thấy lớp ${className}`
+ });
+ continue;
+ }
+
+ // Tạo username và password
+ const username = generateUsername(fullName, dateOfBirth);
+ const password = generateDefaultPassword(dateOfBirth);
+ const email = `${username}@apdinh.edu.vn`;
+ const studentCode = username.toUpperCase();
+
+ console.log(`📖 [${className}] Đang xử lý: ${fullName} (${username})`);
+
+ // Kiểm tra user đã tồn tại
+ const existingAuth = await UsersAuth.findOne({
+ where: { username }
+ });
+
+ if (existingAuth) {
+ console.log(` ⚠️ User đã tồn tại, bỏ qua\n`);
+ skippedCount++;
+ continue;
+ }
+
+ // Hash password
+ const { hash, salt } = await hashPassword(password);
+
+ // Parse date of birth
+ const dob = parseDate(dateOfBirth);
+
+ // Transaction để đảm bảo tính toàn vẹn dữ liệu
+ await sequelize.transaction(async (t) => {
+ // 5a. Tạo UsersAuth
+ const userAuth = await UsersAuth.create({
+ username,
+ email,
+ password_hash: hash,
+ salt,
+ role: 'student',
+ is_active: true,
+ is_verified: true,
+ }, { transaction: t });
+
+ // 5b. Tạo UserProfile
+ const nameParts = fullName.trim().split(/\s+/);
+ const lastName = nameParts[0];
+ const firstName = nameParts.slice(1).join(' ');
+
+ const userProfile = await UserProfile.create({
+ user_id: userAuth.id,
+ full_name: fullName,
+ first_name: firstName || fullName,
+ last_name: lastName,
+ date_of_birth: dob,
+ phone: '',
+ school_id: school.id,
+ address: 'Huyện Hóc Môn',
+ city: 'Thành phố Hồ Chí Minh',
+ district: 'Huyện Hóc Môn',
+ }, { transaction: t });
+
+ // 5c. Tạo StudentDetail với class_id
+ await StudentDetail.create({
+ user_id: userAuth.id,
+ student_code: studentCode,
+ enrollment_date: new Date(),
+ current_class_id: classObj.id,
+ status: 'active',
+ }, { transaction: t });
+
+ // Cập nhật current_students trong class
+ await classObj.increment('current_students', { transaction: t });
+ });
+
+ console.log(` ✅ Tạo thành công (Lớp: ${classObj.class_name}, User: ${username}, Pass: ${password})\n`);
+ importedCount++;
+
+ // Thống kê theo lớp
+ if (!classStats[classObj.class_name]) {
+ classStats[classObj.class_name] = 0;
+ }
+ classStats[classObj.class_name]++;
+
+ } catch (error) {
+ console.error(` ❌ Lỗi: ${error.message}\n`);
+ errors.push({
+ line,
+ error: error.message
+ });
+ }
+ }
+
+ // Tổng kết
+ console.log('\n' + '='.repeat(70));
+ console.log('📊 KẾT QUẢ IMPORT TRƯỜNG TIỂU HỌC ẤP ĐÌNH');
+ console.log('='.repeat(70));
+ console.log(`✅ Học sinh mới: ${importedCount}`);
+ console.log(`⚠️ Đã tồn tại: ${skippedCount}`);
+ console.log(`❌ Lỗi: ${errors.length}`);
+ console.log('='.repeat(70));
+
+ // Thống kê theo lớp
+ console.log('\n📚 THỐNG KÊ THEO LỚP:');
+ const sortedClasses = Object.keys(classStats).sort();
+ for (const className of sortedClasses) {
+ console.log(` ${className}: ${classStats[className]} học sinh`);
+ }
+
+ if (errors.length > 0) {
+ console.log('\n⚠️ CHI TIẾT LỖI:');
+ errors.slice(0, 10).forEach((err, index) => {
+ console.log(`\n${index + 1}. ${err.line}`);
+ console.log(` Lỗi: ${err.error}`);
+ });
+ if (errors.length > 10) {
+ console.log(`\n ... và ${errors.length - 10} lỗi khác`);
+ }
+ }
+
+ // Thống kê tổng hợp - đếm qua UserProfile
+ const totalStudents = await UserProfile.count({
+ where: { school_id: school.id }
+ });
+
+ console.log('\n📊 THỐNG KÊ TỔNG HỢP:');
+ console.log(` Tổng số học sinh trường Ấp Đình: ${totalStudents}`);
+ console.log(` Tổng số lớp: ${classes.length}`);
+
+ console.log('\n✅ Hoàn thành import dữ liệu!\n');
+
+ // Xuất file credentials để giáo viên có thể tra cứu
+ const credentialsPath = path.join(__dirname, '../../data/apdinh-credentials.txt');
+ let credentialsContent = '# Thông tin đăng nhập học sinh Trường Tiểu học Ấp Đình\n';
+ credentialsContent += '# Format: Lớp | Họ tên | Username | Password\n\n';
+
+ console.log(`📄 Đang tạo file thông tin đăng nhập: apdinh-credentials.txt\n`);
+
+ } catch (error) {
+ console.error('❌ Lỗi nghiêm trọng:', error);
+ throw error;
+ } finally {
+ await sequelize.close();
+ console.log('🔌 Đã đóng kết nối database');
+ }
+}
+
+// Chạy script
+if (require.main === module) {
+ importApdinhStudents()
+ .then(() => {
+ console.log('\n🎉 Script hoàn thành thành công!');
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('\n💥 Script thất bại:', error);
+ process.exit(1);
+ });
+}
+
+module.exports = { importApdinhStudents };
diff --git a/scripts/import-foreign-teachers.js b/scripts/import-foreign-teachers.js
new file mode 100644
index 0000000..93847da
--- /dev/null
+++ b/scripts/import-foreign-teachers.js
@@ -0,0 +1,201 @@
+const { sequelize } = require('../config/database');
+const UsersAuth = require('../models/UsersAuth');
+const UserProfile = require('../models/UserProfile');
+const TeacherDetail = require('../models/TeacherDetail');
+const School = require('../models/School');
+const bcrypt = require('bcrypt');
+const fs = require('fs');
+const path = require('path');
+
+/**
+ * Script import giáo viên nước ngoài từ fteacher.txt
+ * Chạy: node src/scripts/import-foreign-teachers.js
+ */
+
+// Hàm hash password
+async function hashPassword(password) {
+ const salt = await bcrypt.genSalt(10);
+ const hash = await bcrypt.hash(password, salt);
+ return { hash, salt };
+}
+
+// Hàm parse tên từ username
+function parseNameFromUsername(username) {
+ // tdv_altaf -> Altaf
+ const name = username.replace('tdv_', '').replace(/_/g, ' ');
+ return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
+}
+
+// Hàm tạo email từ username
+function generateEmail(username) {
+ // tdv_altaf -> altaf@senaai.edu.vn
+ const name = username.replace('tdv_', '');
+ return `${name}@senaai.edu.vn`;
+}
+
+async function importForeignTeachers() {
+ try {
+ console.log('🚀 Bắt đầu import giáo viên nước ngoài...\n');
+
+ // Đọc file fteacher.txt
+ const filePath = path.join(__dirname, '../../data/fteacher.txt');
+ const fileContent = fs.readFileSync(filePath, 'utf8');
+ const lines = fileContent.split('\n').filter(line => line.trim());
+
+ console.log(`📚 Tìm thấy ${lines.length} giáo viên trong file\n`);
+
+ // Kết nối database
+ await sequelize.authenticate();
+ console.log('✅ Kết nối database thành công\n');
+
+ // Lấy thông tin trung tâm SenaAI
+ const senaAI = await School.findOne({
+ where: { school_code: 'SENAAI' }
+ });
+
+ if (!senaAI) {
+ throw new Error('❌ Không tìm thấy trung tâm SenaAI trong database. Vui lòng chạy add-senaai-center.js trước!');
+ }
+
+ let importedCount = 0;
+ let skippedCount = 0;
+ const errors = [];
+
+ // Parse từng dòng
+ for (const line of lines) {
+ try {
+ // Parse: Created user: tdv_altaf with password: 13082025
+ const match = line.match(/Created user: (\S+) with password: (\S+)/);
+
+ if (!match) {
+ console.log(`⚠️ Bỏ qua dòng không hợp lệ: ${line}`);
+ continue;
+ }
+
+ const [, username, password] = match;
+ const fullName = parseNameFromUsername(username);
+ const email = generateEmail(username);
+ const teacherCode = username.toUpperCase();
+
+ console.log(`📖 Đang xử lý: ${fullName} (${username})`);
+
+ // Kiểm tra user đã tồn tại
+ const existingAuth = await UsersAuth.findOne({
+ where: { username }
+ });
+
+ if (existingAuth) {
+ console.log(` ⚠️ User đã tồn tại, bỏ qua\n`);
+ skippedCount++;
+ continue;
+ }
+
+ // Hash password
+ const { hash, salt } = await hashPassword(password);
+
+ // Transaction để đảm bảo tính toàn vẹn dữ liệu
+ await sequelize.transaction(async (t) => {
+ // 1. Tạo UsersAuth
+ const userAuth = await UsersAuth.create({
+ username,
+ email,
+ password_hash: hash,
+ salt,
+ role: 'teacher',
+ is_active: true,
+ is_verified: true,
+ }, { transaction: t });
+
+ // 2. Tạo UserProfile
+ const userProfile = await UserProfile.create({
+ user_id: userAuth.id,
+ full_name: fullName,
+ first_name: fullName.split(' ')[0],
+ last_name: fullName.split(' ').slice(1).join(' ') || fullName,
+ phone: '',
+ school_id: senaAI.id,
+ address: 'SenaAI Education Center',
+ city: 'Thành phố Hồ Chí Minh',
+ district: 'Đang cập nhật',
+ }, { transaction: t });
+
+ // 3. Tạo TeacherDetail
+ await TeacherDetail.create({
+ user_id: userAuth.id,
+ teacher_code: teacherCode,
+ teacher_type: 'foreign',
+ qualification: 'Native Speaker',
+ specialization: 'English Language Teaching',
+ hire_date: new Date(),
+ status: 'active',
+ skill_tags: [
+ { name: 'English Teaching', level: 'expert' },
+ { name: 'Communication', level: 'expert' }
+ ],
+ certifications: [],
+ training_hours: 0,
+ }, { transaction: t });
+ });
+
+ console.log(` ✅ Tạo thành công (Email: ${email})\n`);
+ importedCount++;
+
+ } catch (error) {
+ console.error(` ❌ Lỗi: ${error.message}\n`);
+ errors.push({
+ line,
+ error: error.message
+ });
+ }
+ }
+
+ // Tổng kết
+ console.log('\n' + '='.repeat(60));
+ console.log('📊 KẾT QUẢ IMPORT');
+ console.log('='.repeat(60));
+ console.log(`✅ Giáo viên mới: ${importedCount}`);
+ console.log(`⚠️ Đã tồn tại: ${skippedCount}`);
+ console.log(`❌ Lỗi: ${errors.length}`);
+ console.log('='.repeat(60));
+
+ if (errors.length > 0) {
+ console.log('\n⚠️ CHI TIẾT LỖI:');
+ errors.forEach((err, index) => {
+ console.log(`\n${index + 1}. ${err.line}`);
+ console.log(` Lỗi: ${err.error}`);
+ });
+ }
+
+ // Thống kê giáo viên nước ngoài
+ const foreignTeachersCount = await TeacherDetail.count({
+ where: { teacher_type: 'foreign' }
+ });
+
+ console.log('\n📊 THỐNG KÊ:');
+ console.log(` Tổng số giáo viên nước ngoài: ${foreignTeachersCount}`);
+
+ console.log('\n✅ Hoàn thành import dữ liệu!\n');
+
+ } catch (error) {
+ console.error('❌ Lỗi nghiêm trọng:', error);
+ throw error;
+ } finally {
+ await sequelize.close();
+ console.log('🔌 Đã đóng kết nối database');
+ }
+}
+
+// Chạy script
+if (require.main === module) {
+ importForeignTeachers()
+ .then(() => {
+ console.log('\n🎉 Script hoàn thành thành công!');
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('\n💥 Script thất bại:', error);
+ process.exit(1);
+ });
+}
+
+module.exports = { importForeignTeachers };
diff --git a/scripts/import-schools.js b/scripts/import-schools.js
new file mode 100644
index 0000000..c91eaa2
--- /dev/null
+++ b/scripts/import-schools.js
@@ -0,0 +1,183 @@
+const { sequelize } = require('../config/database');
+const School = require('../models/School');
+const Class = require('../models/Class');
+const fs = require('fs');
+const path = require('path');
+
+/**
+ * Script import dữ liệu trường và lớp học từ schools.json
+ * Chạy: node src/scripts/import-schools.js
+ */
+
+async function importSchoolsData() {
+ try {
+ console.log('🚀 Bắt đầu import dữ liệu trường học...\n');
+
+ // Đọc file schools.json
+ const schoolsDataPath = path.join(__dirname, '../../data/schools.json');
+ const schoolsData = JSON.parse(fs.readFileSync(schoolsDataPath, 'utf8'));
+
+ console.log(`📚 Tìm thấy ${schoolsData.length} trường học trong file\n`);
+
+ // Kết nối database
+ await sequelize.authenticate();
+ console.log('✅ Kết nối database thành công\n');
+
+ // Đồng bộ models (tạo bảng nếu chưa có)
+ await sequelize.sync({ alter: false });
+ console.log('✅ Đồng bộ database models thành công\n');
+
+ let importedSchools = 0;
+ let importedClasses = 0;
+ let skippedSchools = 0;
+ const errors = [];
+
+ // Import từng trường
+ for (const schoolData of schoolsData) {
+ try {
+ console.log(`📖 Đang xử lý: ${schoolData.school_name} (${schoolData.school_code})`);
+
+ // Kiểm tra trường đã tồn tại chưa
+ let school = await School.findOne({
+ where: { school_code: schoolData.school_code }
+ });
+
+ if (school) {
+ console.log(` ⚠️ Trường đã tồn tại, cập nhật thông tin...`);
+
+ // Cập nhật thông tin trường
+ await school.update({
+ school_name: schoolData.school_name,
+ school_type: schoolData.school_type,
+ address: schoolData.address,
+ city: schoolData.city,
+ district: schoolData.district,
+ phone: schoolData.phone,
+ });
+ skippedSchools++;
+ } else {
+ // Tạo mới trường
+ school = await School.create({
+ school_code: schoolData.school_code,
+ school_name: schoolData.school_name,
+ school_type: schoolData.school_type,
+ address: schoolData.address,
+ city: schoolData.city,
+ district: schoolData.district,
+ phone: schoolData.phone,
+ is_active: true,
+ });
+ importedSchools++;
+ console.log(` ✅ Tạo mới trường thành công`);
+ }
+
+ // Import các lớp học
+ if (schoolData.classes && Array.isArray(schoolData.classes)) {
+ console.log(` 📝 Đang import ${schoolData.classes.length} lớp học...`);
+
+ for (const classData of schoolData.classes) {
+ try {
+ // Xử lý định dạng classes khác nhau trong JSON
+ let classNames = [];
+ let gradeLevel = null;
+
+ if (classData.name) {
+ // Format 1: { "grade": 1, "name": "1A" }
+ classNames = [classData.name];
+ gradeLevel = classData.grade;
+ } else if (classData.names) {
+ // Format 2: { "grade": 3, "names": ["3,1", "3,2", ...] }
+ classNames = classData.names;
+ gradeLevel = classData.grade;
+ }
+
+ // Tạo từng lớp
+ for (const className of classNames) {
+ // Tạo class_code duy nhất
+ const classCode = `${schoolData.school_code}_${className.replace(/[\/,\s.]/g, '_')}`;
+
+ // Kiểm tra lớp đã tồn tại chưa
+ const existingClass = await Class.findOne({
+ where: { class_code: classCode }
+ });
+
+ if (!existingClass) {
+ await Class.create({
+ class_code: classCode,
+ class_name: className,
+ school_id: school.id,
+ academic_year_id: '00000000-0000-0000-0000-000000000000', // Temporary UUID
+ grade_level: gradeLevel,
+ max_students: 35,
+ current_students: 0,
+ });
+ importedClasses++;
+ }
+ }
+ } catch (classError) {
+ console.error(` ❌ Lỗi import lớp: ${classError.message}`);
+ errors.push({
+ school: schoolData.school_name,
+ class: classData,
+ error: classError.message
+ });
+ }
+ }
+ }
+
+ console.log('');
+ } catch (schoolError) {
+ console.error(` ❌ Lỗi import trường: ${schoolError.message}\n`);
+ errors.push({
+ school: schoolData.school_name,
+ error: schoolError.message
+ });
+ }
+ }
+
+ // Tổng kết
+ console.log('\n' + '='.repeat(60));
+ console.log('📊 KẾT QUẢ IMPORT');
+ console.log('='.repeat(60));
+ console.log(`✅ Trường học mới: ${importedSchools}`);
+ console.log(`⚠️ Trường đã tồn tại: ${skippedSchools}`);
+ console.log(`✅ Lớp học mới: ${importedClasses}`);
+ console.log(`❌ Lỗi: ${errors.length}`);
+ console.log('='.repeat(60));
+
+ if (errors.length > 0) {
+ console.log('\n⚠️ CHI TIẾT LỖI:');
+ errors.forEach((err, index) => {
+ console.log(`\n${index + 1}. ${err.school || 'Unknown'}`);
+ console.log(` Lỗi: ${err.error}`);
+ if (err.class) {
+ console.log(` Lớp: ${JSON.stringify(err.class)}`);
+ }
+ });
+ }
+
+ console.log('\n✅ Hoàn thành import dữ liệu!\n');
+
+ } catch (error) {
+ console.error('❌ Lỗi nghiêm trọng:', error);
+ throw error;
+ } finally {
+ await sequelize.close();
+ console.log('🔌 Đã đóng kết nối database');
+ }
+}
+
+// Chạy script
+if (require.main === module) {
+ importSchoolsData()
+ .then(() => {
+ console.log('\n🎉 Script hoàn thành thành công!');
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('\n💥 Script thất bại:', error);
+ process.exit(1);
+ });
+}
+
+module.exports = { importSchoolsData };
diff --git a/scripts/sync-database.js b/scripts/sync-database.js
new file mode 100644
index 0000000..16e66ae
--- /dev/null
+++ b/scripts/sync-database.js
@@ -0,0 +1,67 @@
+const { sequelize, setupRelationships, syncDatabase } = require('../models');
+
+/**
+ * Script to sync all Sequelize models to database
+ * This will create all tables with proper foreign keys and indexes
+ */
+async function syncAllTables() {
+ try {
+ console.log('🚀 Starting database synchronization...\n');
+
+ // Setup model relationships first
+ setupRelationships();
+ console.log('✅ Model relationships configured\n');
+
+ // Test database connection
+ await sequelize.authenticate();
+ console.log('✅ Database connection established\n');
+
+ // Show current tables
+ const [tablesBefore] = await sequelize.query(`
+ SELECT TABLE_NAME
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE()
+ ORDER BY TABLE_NAME;
+ `);
+ console.log('📊 Tables before sync:', tablesBefore.length);
+ console.log(tablesBefore.map(t => t.TABLE_NAME).join(', '), '\n');
+
+ // Sync database (force: false = don't drop existing tables)
+ // Use { alter: true } to modify existing tables without dropping data
+ // Use { force: true } to drop and recreate all tables (CAREFUL: data loss!)
+ console.log('⚙️ Syncing database with alter mode...');
+ await syncDatabase({ alter: true });
+
+ // Show tables after sync
+ const [tablesAfter] = await sequelize.query(`
+ SELECT TABLE_NAME
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE()
+ ORDER BY TABLE_NAME;
+ `);
+ console.log('\n📊 Tables after sync:', tablesAfter.length);
+ console.log(tablesAfter.map(t => t.TABLE_NAME).join(', '), '\n');
+
+ // Show newly created tables
+ const newTables = tablesAfter.filter(
+ t => !tablesBefore.some(b => b.TABLE_NAME === t.TABLE_NAME)
+ );
+ if (newTables.length > 0) {
+ console.log('✨ Newly created tables:', newTables.length);
+ console.log(newTables.map(t => t.TABLE_NAME).join(', '), '\n');
+ }
+
+ console.log('╔════════════════════════════════════════════════════════╗');
+ console.log('║ ✅ DATABASE SYNC COMPLETED SUCCESSFULLY ║');
+ console.log('╚════════════════════════════════════════════════════════╝');
+
+ process.exit(0);
+ } catch (error) {
+ console.error('\n❌ Database sync failed:');
+ console.error(error);
+ process.exit(1);
+ }
+}
+
+// Run sync
+syncAllTables();
diff --git a/scripts/sync-teacher-profiles.js b/scripts/sync-teacher-profiles.js
new file mode 100644
index 0000000..7dfffac
--- /dev/null
+++ b/scripts/sync-teacher-profiles.js
@@ -0,0 +1,91 @@
+/**
+ * Script: Sync Teacher Profiles
+ * Mục đích: Tạo user_profile cho các teacher đã có sẵn trong hệ thống
+ *
+ * Usage:
+ * node src/scripts/sync-teacher-profiles.js
+ * node src/scripts/sync-teacher-profiles.js --teacher-id=uuid
+ * node src/scripts/sync-teacher-profiles.js --dry-run
+ */
+
+require('dotenv').config();
+const teacherProfileService = require('../services/teacherProfileService');
+const { sequelize } = require('../config/database');
+
+async function main() {
+ console.log('🚀 Starting Teacher Profile Sync...\n');
+
+ try {
+ // Parse command line arguments
+ const args = process.argv.slice(2);
+ const dryRun = args.includes('--dry-run');
+ const teacherIdArg = args.find(arg => arg.startsWith('--teacher-id='));
+ const teacherId = teacherIdArg ? teacherIdArg.split('=')[1] : null;
+
+ if (dryRun) {
+ console.log('⚠️ DRY RUN MODE - No changes will be made\n');
+ }
+
+ if (teacherId) {
+ console.log(`🎯 Syncing specific teacher: ${teacherId}\n`);
+ } else {
+ console.log('🔄 Syncing all teachers without profiles\n');
+ }
+
+ // Test database connection
+ await sequelize.authenticate();
+ console.log('✅ Database connection established\n');
+
+ if (dryRun) {
+ console.log('Dry run completed. Use without --dry-run to apply changes.');
+ process.exit(0);
+ }
+
+ // Execute sync
+ console.log('Processing...\n');
+ const results = await teacherProfileService.syncExistingTeachers(teacherId);
+
+ // Display results
+ console.log('\n' + '='.repeat(60));
+ console.log('📊 SYNC RESULTS');
+ console.log('='.repeat(60));
+
+ console.log(`\n✅ Success: ${results.success.length}`);
+ if (results.success.length > 0) {
+ results.success.forEach((item, index) => {
+ console.log(` ${index + 1}. ${item.teacher_code} → Profile created (user_id: ${item.user_id})`);
+ });
+ }
+
+ console.log(`\n⏭️ Skipped: ${results.skipped.length}`);
+ if (results.skipped.length > 0) {
+ results.skipped.slice(0, 5).forEach((item, index) => {
+ console.log(` ${index + 1}. ${item.teacher_code} - ${item.reason}`);
+ });
+ if (results.skipped.length > 5) {
+ console.log(` ... and ${results.skipped.length - 5} more`);
+ }
+ }
+
+ console.log(`\n❌ Failed: ${results.failed.length}`);
+ if (results.failed.length > 0) {
+ results.failed.forEach((item, index) => {
+ console.log(` ${index + 1}. ${item.teacher_code} - ${item.reason || item.error}`);
+ });
+ }
+
+ console.log('\n' + '='.repeat(60));
+ console.log('✅ Sync completed successfully!');
+ console.log('='.repeat(60) + '\n');
+
+ process.exit(0);
+
+ } catch (error) {
+ console.error('\n❌ Sync failed:', error.message);
+ console.error(error.stack);
+ process.exit(1);
+ }
+}
+
+// Run
+main();
diff --git a/scripts/sync-user-tables.js b/scripts/sync-user-tables.js
new file mode 100644
index 0000000..925da5b
--- /dev/null
+++ b/scripts/sync-user-tables.js
@@ -0,0 +1,58 @@
+const { sequelize } = require('../config/database');
+const UsersAuth = require('../models/UsersAuth');
+const UserProfile = require('../models/UserProfile');
+const TeacherDetail = require('../models/TeacherDetail');
+
+/**
+ * Script tạo các bảng user cần thiết trong database
+ * Chạy: node src/scripts/sync-user-tables.js
+ */
+
+async function syncUserTables() {
+ try {
+ console.log('🚀 Đang tạo các bảng user trong database...\n');
+
+ // Kết nối database
+ await sequelize.authenticate();
+ console.log('✅ Kết nối database thành công\n');
+
+ // Sync các bảng theo thứ tự phụ thuộc
+ console.log('📋 Đang tạo bảng users_auth...');
+ await UsersAuth.sync({ alter: true });
+ console.log('✅ Đã tạo bảng users_auth\n');
+
+ console.log('📋 Đang tạo bảng user_profiles...');
+ await UserProfile.sync({ alter: true });
+ console.log('✅ Đã tạo bảng user_profiles\n');
+
+ console.log('📋 Đang tạo bảng teacher_details...');
+ await TeacherDetail.sync({ alter: true });
+ console.log('✅ Đã tạo bảng teacher_details\n');
+
+ console.log('='.repeat(60));
+ console.log('✅ Đã tạo tất cả các bảng thành công!');
+ console.log('='.repeat(60));
+
+ } catch (error) {
+ console.error('❌ Lỗi:', error);
+ throw error;
+ } finally {
+ await sequelize.close();
+ console.log('\n🔌 Đã đóng kết nối database');
+ }
+}
+
+// Chạy script
+if (require.main === module) {
+ syncUserTables()
+ .then(() => {
+ console.log('\n🎉 Script hoàn thành thành công!');
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('\n💥 Script thất bại:', error);
+ process.exit(1);
+ });
+}
+
+module.exports = { syncUserTables };
diff --git a/scripts/test-api-teachers.js b/scripts/test-api-teachers.js
new file mode 100644
index 0000000..c9dc67a
--- /dev/null
+++ b/scripts/test-api-teachers.js
@@ -0,0 +1,76 @@
+/**
+ * Test API endpoint for teachers
+ */
+
+const API_BASE = 'http://localhost:4000/api';
+
+async function testTeachersAPI() {
+ console.log('🧪 Testing Teachers API\n');
+ console.log('='.repeat(60));
+
+ try {
+ // Test 1: Get all teachers with school filter
+ console.log('\n📝 Test 1: GET /api/teachers with school_id filter');
+ console.log('URL: ' + API_BASE + '/teachers?school_id=95ec32e9-cdcb-4f3a-a8ee-8af20bdfe2cc&page=1&limit=5');
+
+ const response1 = await fetch(`${API_BASE}/teachers?school_id=95ec32e9-cdcb-4f3a-a8ee-8af20bdfe2cc&page=1&limit=5`);
+ const data1 = await response1.json();
+
+ if (data1.success) {
+ console.log('✅ Success!');
+ console.log(` Total: ${data1.data.pagination.total}`);
+ console.log(` Returned: ${data1.data.teachers.length}`);
+
+ if (data1.data.teachers.length > 0) {
+ const teacher = data1.data.teachers[0];
+ console.log('\n📋 Sample Teacher:');
+ console.log(` ID: ${teacher.id}`);
+ console.log(` Code: ${teacher.teacher_code}`);
+ console.log(` Type: ${teacher.teacher_type}`);
+ console.log(` Status: ${teacher.status}`);
+ console.log(` Profile: ${teacher.profile ? '✅ EXISTS' : '❌ NULL'}`);
+
+ if (teacher.profile) {
+ console.log(` - Full Name: ${teacher.profile.full_name}`);
+ console.log(` - Phone: ${teacher.profile.phone || 'N/A'}`);
+ console.log(` - School ID: ${teacher.profile.school_id}`);
+ }
+ }
+ } else {
+ console.log('❌ Failed:', data1.message);
+ }
+
+ // Test 2: Get all teachers without filter
+ console.log('\n\n📝 Test 2: GET /api/teachers (no filter)');
+ console.log('URL: ' + API_BASE + '/teachers?page=1&limit=3');
+
+ const response2 = await fetch(`${API_BASE}/teachers?page=1&limit=3`);
+ const data2 = await response2.json();
+
+ if (data2.success) {
+ console.log('✅ Success!');
+ console.log(` Total: ${data2.data.pagination.total}`);
+ console.log(` Returned: ${data2.data.teachers.length}`);
+
+ console.log('\n📋 Teachers:');
+ data2.data.teachers.forEach((t, i) => {
+ console.log(` ${i + 1}. ${t.teacher_code} - Profile: ${t.profile ? '✅' : '❌'}`);
+ if (t.profile) {
+ console.log(` Name: ${t.profile.full_name}`);
+ }
+ });
+ } else {
+ console.log('❌ Failed:', data2.message);
+ }
+
+ console.log('\n' + '='.repeat(60));
+ console.log('✅ Tests completed!');
+ console.log('='.repeat(60) + '\n');
+
+ } catch (error) {
+ console.error('\n❌ Test failed:', error.message);
+ }
+}
+
+// Run test
+testTeachersAPI();
diff --git a/scripts/test-profile-logic.js b/scripts/test-profile-logic.js
new file mode 100644
index 0000000..91935c6
--- /dev/null
+++ b/scripts/test-profile-logic.js
@@ -0,0 +1,85 @@
+/**
+ * Test: Verify profile fetching logic
+ */
+
+require('dotenv').config();
+const { sequelize, TeacherDetail, UserProfile } = require('../models');
+
+async function testProfileFetch() {
+ console.log('🧪 Testing Profile Fetch Logic\n');
+
+ try {
+ await sequelize.authenticate();
+ console.log('✅ Database connected\n');
+
+ // Get first teacher
+ const teacher = await TeacherDetail.findOne({
+ order: [['created_at', 'ASC']],
+ });
+
+ if (!teacher) {
+ console.log('❌ No teachers found');
+ process.exit(1);
+ }
+
+ console.log('📋 Teacher Found:');
+ console.log(` ID: ${teacher.id}`);
+ console.log(` Code: ${teacher.teacher_code}`);
+ console.log(` User ID: ${teacher.user_id}`);
+ console.log('');
+
+ // Fetch profile manually
+ console.log('🔍 Fetching profile manually...');
+ const profile = await UserProfile.findOne({
+ where: { user_id: teacher.user_id },
+ });
+
+ if (profile) {
+ console.log('✅ Profile found!');
+ console.log(` Profile ID: ${profile.id}`);
+ console.log(` Full Name: ${profile.full_name}`);
+ console.log(` Phone: ${profile.phone || 'N/A'}`);
+ console.log(` School ID: ${profile.school_id}`);
+ console.log('');
+
+ // Create combined object like in controller
+ const teacherData = {
+ ...teacher.toJSON(),
+ profile: profile.toJSON(),
+ };
+
+ console.log('✅ Combined data structure works!');
+ console.log('');
+ console.log('Sample response:');
+ console.log(JSON.stringify({
+ id: teacherData.id,
+ teacher_code: teacherData.teacher_code,
+ teacher_type: teacherData.teacher_type,
+ status: teacherData.status,
+ profile: {
+ id: teacherData.profile.id,
+ full_name: teacherData.profile.full_name,
+ phone: teacherData.profile.phone,
+ school_id: teacherData.profile.school_id,
+ }
+ }, null, 2));
+
+ } else {
+ console.log('❌ Profile NOT found!');
+ console.log(' This is the issue - profile should exist');
+ }
+
+ console.log('\n' + '='.repeat(60));
+ console.log('✅ Test completed!');
+ console.log('='.repeat(60));
+
+ process.exit(0);
+
+ } catch (error) {
+ console.error('❌ Error:', error.message);
+ console.error(error.stack);
+ process.exit(1);
+ }
+}
+
+testProfileFetch();
diff --git a/scripts/test-student-profile-join.js b/scripts/test-student-profile-join.js
new file mode 100644
index 0000000..84371be
--- /dev/null
+++ b/scripts/test-student-profile-join.js
@@ -0,0 +1,105 @@
+const { sequelize } = require('../config/database');
+const { StudentDetail, UserProfile, UsersAuth, setupRelationships } = require('../models');
+
+async function testJoin() {
+ try {
+ await sequelize.authenticate();
+ console.log('✅ Connected to database');
+
+ // Setup relationships BEFORE querying
+ setupRelationships();
+ console.log('✅ Relationships initialized\n');
+
+ // Test 1: Raw SQL query
+ console.log('📊 Test 1: Raw SQL Join');
+ const [rawResults] = await sequelize.query(`
+ SELECT
+ sd.id,
+ sd.user_id,
+ sd.student_code,
+ up.id as profile_id,
+ up.user_id as profile_user_id,
+ up.full_name
+ FROM student_details sd
+ LEFT JOIN user_profiles up ON sd.user_id = up.user_id
+ WHERE sd.student_code LIKE 'pri_apdinh%'
+ LIMIT 3
+ `);
+
+ console.log('Raw SQL Results:');
+ rawResults.forEach(row => {
+ console.log({
+ student_id: row.id,
+ student_user_id: row.user_id,
+ student_code: row.student_code,
+ profile_id: row.profile_id,
+ profile_user_id: row.profile_user_id,
+ full_name: row.full_name,
+ join_works: row.profile_id ? '✅ YES' : '❌ NO'
+ });
+ });
+
+ // Test 2: Sequelize query
+ console.log('\n📊 Test 2: Sequelize Join');
+ const students = await StudentDetail.findAll({
+ where: sequelize.where(
+ sequelize.fn('LOWER', sequelize.col('student_code')),
+ 'LIKE',
+ 'pri_apdinh%'
+ ),
+ include: [{
+ model: UserProfile,
+ as: 'profile',
+ required: false,
+ }],
+ limit: 3
+ });
+
+ console.log('Sequelize Results:');
+ students.forEach(student => {
+ console.log({
+ student_id: student.id,
+ student_user_id: student.user_id,
+ student_code: student.student_code,
+ profile: student.profile ? {
+ id: student.profile.id,
+ user_id: student.profile.user_id,
+ full_name: student.profile.full_name,
+ join_works: '✅ YES'
+ } : '❌ NO PROFILE'
+ });
+ });
+
+ // Test 3: Check data integrity
+ console.log('\n📊 Test 3: Data Integrity Check');
+ const sampleStudent = await StudentDetail.findOne({
+ where: sequelize.where(
+ sequelize.fn('LOWER', sequelize.col('student_code')),
+ 'LIKE',
+ 'pri_apdinh%'
+ )
+ });
+
+ if (sampleStudent) {
+ console.log(`Student user_id: ${sampleStudent.user_id}`);
+
+ const matchingProfile = await UserProfile.findOne({
+ where: { user_id: sampleStudent.user_id }
+ });
+
+ if (matchingProfile) {
+ console.log(`✅ Found matching profile: ${matchingProfile.full_name}`);
+ console.log(`Profile user_id: ${matchingProfile.user_id}`);
+ } else {
+ console.log(`❌ No matching profile found for user_id: ${sampleStudent.user_id}`);
+ }
+ }
+
+ } catch (error) {
+ console.error('❌ Error:', error);
+ } finally {
+ await sequelize.close();
+ }
+}
+
+testJoin();
diff --git a/scripts/test-teacher-profile.js b/scripts/test-teacher-profile.js
new file mode 100644
index 0000000..1f10802
--- /dev/null
+++ b/scripts/test-teacher-profile.js
@@ -0,0 +1,267 @@
+/**
+ * Test Teacher Profile Service
+ *
+ * Usage:
+ * node src/scripts/test-teacher-profile.js
+ */
+
+require('dotenv').config();
+const teacherProfileService = require('../services/teacherProfileService');
+const { sequelize, TeacherDetail, UserProfile, UsersAuth } = require('../models');
+
+async function testCreateTeacher() {
+ console.log('\n📝 Test 1: Create teacher với minimal data');
+ console.log('='.repeat(60));
+
+ try {
+ const minimalTeacher = {
+ teacher_code: `TEST_GV_${Date.now()}`,
+ teacher_type: 'homeroom',
+ };
+
+ console.log('Input:', JSON.stringify(minimalTeacher, null, 2));
+
+ const result = await teacherProfileService.createTeacherWithProfile(minimalTeacher);
+
+ console.log('\n✅ Success!');
+ console.log('Output:', JSON.stringify({
+ user_id: result.user_id,
+ username: result.username,
+ email: result.email,
+ temporary_password: result.temporary_password,
+ teacher_code: result.teacher_detail.teacher_code,
+ full_name: result.profile.full_name,
+ }, null, 2));
+
+ return result;
+
+ } catch (error) {
+ console.error('❌ Failed:', error.message);
+ throw error;
+ }
+}
+
+async function testCreateForeignTeacher() {
+ console.log('\n📝 Test 2: Create foreign teacher với full data');
+ console.log('='.repeat(60));
+
+ try {
+ const fullTeacher = {
+ // Required
+ teacher_code: `TEST_FT_${Date.now()}`,
+ teacher_type: 'foreign',
+
+ // Optional auth
+ username: 'test_john_smith',
+ email: `test_john_${Date.now()}@sena.edu.vn`,
+ password: 'TestPassword123!',
+
+ // Optional profile
+ full_name: 'John Smith',
+ first_name: 'John',
+ last_name: 'Smith',
+ phone: '+84-123-456-789',
+ date_of_birth: '1985-03-15',
+ gender: 'male',
+ address: '123 Test Street',
+ city: 'Ho Chi Minh City',
+ district: 'District 1',
+
+ // Optional teacher details
+ qualification: 'Master of Education',
+ specialization: 'English Literature',
+ hire_date: '2026-01-01',
+ skill_tags: [
+ { name: 'IELTS', level: 'expert' },
+ { name: 'TOEFL', level: 'advanced' }
+ ],
+ certifications: [
+ { name: 'TESOL', issued_by: 'Cambridge', issued_date: '2020-05-15' }
+ ]
+ };
+
+ console.log('Input:', JSON.stringify(fullTeacher, null, 2));
+
+ const result = await teacherProfileService.createTeacherWithProfile(fullTeacher);
+
+ console.log('\n✅ Success!');
+ console.log('Output:', JSON.stringify({
+ user_id: result.user_id,
+ username: result.username,
+ email: result.email,
+ temporary_password: result.temporary_password, // Should be null
+ teacher_code: result.teacher_detail.teacher_code,
+ full_name: result.profile.full_name,
+ phone: result.profile.phone,
+ needs_update: result.profile.etc?.needs_profile_update,
+ }, null, 2));
+
+ return result;
+
+ } catch (error) {
+ console.error('❌ Failed:', error.message);
+ throw error;
+ }
+}
+
+async function testUpdateProfile(userId) {
+ console.log('\n📝 Test 3: Update teacher profile');
+ console.log('='.repeat(60));
+
+ try {
+ const updateData = {
+ full_name: 'John Smith Updated',
+ phone: '+84-987-654-321',
+ address: '456 New Address',
+ };
+
+ console.log('User ID:', userId);
+ console.log('Update data:', JSON.stringify(updateData, null, 2));
+
+ const result = await teacherProfileService.updateTeacherProfile(userId, updateData);
+
+ console.log('\n✅ Success!');
+ console.log('Updated profile:', JSON.stringify({
+ full_name: result.full_name,
+ phone: result.phone,
+ address: result.address,
+ needs_update: result.etc?.needs_profile_update,
+ last_updated: result.etc?.last_updated,
+ }, null, 2));
+
+ return result;
+
+ } catch (error) {
+ console.error('❌ Failed:', error.message);
+ throw error;
+ }
+}
+
+async function testSync() {
+ console.log('\n📝 Test 4: Sync existing teachers');
+ console.log('='.repeat(60));
+
+ try {
+ // First, create a teacher_detail without profile (simulate old data)
+ const testUserAuth = await UsersAuth.create({
+ username: `test_orphan_${Date.now()}`,
+ email: `test_orphan_${Date.now()}@sena.edu.vn`,
+ password_hash: 'dummy_hash',
+ salt: 'dummy_salt',
+ });
+
+ const orphanTeacher = await TeacherDetail.create({
+ user_id: testUserAuth.id,
+ teacher_code: `TEST_ORPHAN_${Date.now()}`,
+ teacher_type: 'subject',
+ });
+
+ console.log(`Created orphan teacher: ${orphanTeacher.teacher_code}`);
+
+ // Now sync
+ const results = await teacherProfileService.syncExistingTeachers(orphanTeacher.id);
+
+ console.log('\n✅ Sync completed!');
+ console.log('Results:', JSON.stringify({
+ success_count: results.success.length,
+ skipped_count: results.skipped.length,
+ failed_count: results.failed.length,
+ success: results.success,
+ skipped: results.skipped,
+ failed: results.failed,
+ }, null, 2));
+
+ return results;
+
+ } catch (error) {
+ console.error('❌ Failed:', error.message);
+ throw error;
+ }
+}
+
+async function cleanup(userIds) {
+ console.log('\n🧹 Cleaning up test data...');
+
+ try {
+ // Delete in correct order due to foreign keys
+ for (const userId of userIds) {
+ await TeacherDetail.destroy({ where: { user_id: userId }, force: true });
+ await UserProfile.destroy({ where: { user_id: userId }, force: true });
+ await UsersAuth.destroy({ where: { id: userId }, force: true });
+ }
+
+ console.log('✅ Cleanup completed');
+
+ } catch (error) {
+ console.error('❌ Cleanup failed:', error.message);
+ }
+}
+
+async function main() {
+ console.log('\n🚀 Starting Teacher Profile Service Tests\n');
+
+ const createdUserIds = [];
+
+ try {
+ // Test connection
+ await sequelize.authenticate();
+ console.log('✅ Database connected\n');
+
+ // Run tests
+ const result1 = await testCreateTeacher();
+ createdUserIds.push(result1.user_id);
+
+ const result2 = await testCreateForeignTeacher();
+ createdUserIds.push(result2.user_id);
+
+ await testUpdateProfile(result2.user_id);
+
+ const syncResult = await testSync();
+ if (syncResult.success.length > 0) {
+ createdUserIds.push(syncResult.success[0].user_id);
+ }
+
+ // Summary
+ console.log('\n' + '='.repeat(60));
+ console.log('📊 TEST SUMMARY');
+ console.log('='.repeat(60));
+ console.log('✅ All tests passed!');
+ console.log(`Created ${createdUserIds.length} test users`);
+ console.log('='.repeat(60));
+
+ // Cleanup
+ const readline = require('readline').createInterface({
+ input: process.stdin,
+ output: process.stdout
+ });
+
+ readline.question('\nDelete test data? (y/n): ', async (answer) => {
+ if (answer.toLowerCase() === 'y') {
+ await cleanup(createdUserIds);
+ } else {
+ console.log('\n⚠️ Test data NOT deleted. User IDs:');
+ createdUserIds.forEach((id, index) => {
+ console.log(` ${index + 1}. ${id}`);
+ });
+ }
+
+ readline.close();
+ process.exit(0);
+ });
+
+ } catch (error) {
+ console.error('\n❌ Test failed:', error.message);
+ console.error(error.stack);
+
+ // Cleanup on error
+ if (createdUserIds.length > 0) {
+ console.log('\nCleaning up partial test data...');
+ await cleanup(createdUserIds);
+ }
+
+ process.exit(1);
+ }
+}
+
+// Run tests
+main();
diff --git a/scripts/verify-schools.js b/scripts/verify-schools.js
new file mode 100644
index 0000000..6a175f7
--- /dev/null
+++ b/scripts/verify-schools.js
@@ -0,0 +1,175 @@
+const { sequelize } = require('../config/database');
+const School = require('../models/School');
+const Class = require('../models/Class');
+
+/**
+ * Script kiểm tra dữ liệu đã import
+ * Chạy: node src/scripts/verify-schools.js
+ */
+
+async function verifyImportedData() {
+ try {
+ console.log('🔍 Kiểm tra dữ liệu đã import...\n');
+
+ // Kết nối database
+ await sequelize.authenticate();
+ console.log('✅ Kết nối database thành công\n');
+
+ // Đếm số lượng trường
+ const schoolCount = await School.count();
+ const activeSchools = await School.count({ where: { is_active: true } });
+
+ console.log('📊 THỐNG KÊ TRƯỜNG HỌC');
+ console.log('='.repeat(60));
+ console.log(`Tổng số trường: ${schoolCount}`);
+ console.log(`Trường đang hoạt động: ${activeSchools}`);
+ console.log('');
+
+ // Thống kê theo loại trường
+ const schoolTypes = await School.findAll({
+ attributes: [
+ 'school_type',
+ [sequelize.fn('COUNT', sequelize.col('id')), 'count']
+ ],
+ group: ['school_type']
+ });
+
+ console.log('📚 Phân loại trường:');
+ schoolTypes.forEach(type => {
+ const typeName = {
+ 'preschool': 'Mầm non',
+ 'primary': 'Tiểu học',
+ 'secondary': 'Trung học cơ sở',
+ 'high_school': 'Trung học phổ thông'
+ }[type.school_type] || type.school_type;
+
+ console.log(` ${typeName.padEnd(25)} ${type.get('count')}`);
+ });
+ console.log('');
+
+ // Thống kê theo quận/huyện
+ const districts = await School.findAll({
+ attributes: [
+ 'district',
+ [sequelize.fn('COUNT', sequelize.col('id')), 'count']
+ ],
+ group: ['district'],
+ order: [[sequelize.fn('COUNT', sequelize.col('id')), 'DESC']],
+ limit: 10
+ });
+
+ console.log('🏙️ Top 10 Quận/Huyện có nhiều trường nhất:');
+ districts.forEach((district, index) => {
+ console.log(` ${(index + 1 + '.').padEnd(4)} ${(district.district || 'N/A').padEnd(30)} ${district.get('count')}`);
+ });
+ console.log('');
+
+ // Đếm số lượng lớp
+ const classCount = await Class.count();
+
+ console.log('📊 THỐNG KÊ LỚP HỌC');
+ console.log('='.repeat(60));
+ console.log(`Tổng số lớp: ${classCount}`);
+ console.log('');
+
+ // Thống kê lớp theo khối
+ const gradeStats = await Class.findAll({
+ attributes: [
+ 'grade_level',
+ [sequelize.fn('COUNT', sequelize.col('id')), 'count']
+ ],
+ group: ['grade_level'],
+ order: ['grade_level']
+ });
+
+ console.log('📝 Phân bổ lớp theo khối:');
+ gradeStats.forEach(stat => {
+ const grade = stat.grade_level || 'N/A';
+ console.log(` Khối ${grade}:`.padEnd(15) + `${stat.get('count')} lớp`);
+ });
+ console.log('');
+
+ // Lấy 5 trường có nhiều lớp nhất
+ const topSchools = await sequelize.query(`
+ SELECT
+ s.school_name,
+ s.school_code,
+ COUNT(c.id) as class_count
+ FROM schools s
+ LEFT JOIN classes c ON s.id = c.school_id
+ GROUP BY s.id, s.school_name, s.school_code
+ ORDER BY class_count DESC
+ LIMIT 5
+ `, {
+ type: sequelize.QueryTypes.SELECT
+ });
+
+ console.log('🏆 Top 5 trường có nhiều lớp nhất:');
+ topSchools.forEach((school, index) => {
+ console.log(` ${index + 1}. ${school.school_name.padEnd(45)} ${school.class_count} lớp`);
+ });
+ console.log('');
+
+ // Chi tiết một trường mẫu
+ const sampleSchool = await School.findOne({
+ include: [{
+ model: Class,
+ as: 'classes',
+ required: true
+ }],
+ order: [[sequelize.literal('(SELECT COUNT(*) FROM classes WHERE school_id = schools.id)'), 'DESC']]
+ });
+
+ if (sampleSchool) {
+ console.log('📖 CHI TIẾT MỘT TRƯỜNG MẪU');
+ console.log('='.repeat(60));
+ console.log(`Tên trường: ${sampleSchool.school_name}`);
+ console.log(`Mã trường: ${sampleSchool.school_code}`);
+ console.log(`Loại: ${sampleSchool.school_type}`);
+ console.log(`Địa chỉ: ${sampleSchool.address}`);
+ console.log(`Quận/Huyện: ${sampleSchool.district}`);
+ console.log(`Số điện thoại: ${sampleSchool.phone || 'N/A'}`);
+
+ if (sampleSchool.classes && sampleSchool.classes.length > 0) {
+ console.log(`\nSố lớp: ${sampleSchool.classes.length}`);
+ console.log('\nDanh sách một số lớp:');
+ sampleSchool.classes.slice(0, 10).forEach(cls => {
+ console.log(` - ${cls.class_name} (Khối ${cls.grade_level})`);
+ });
+ if (sampleSchool.classes.length > 10) {
+ console.log(` ... và ${sampleSchool.classes.length - 10} lớp khác`);
+ }
+ }
+ }
+
+ console.log('\n' + '='.repeat(60));
+ console.log('✅ Kiểm tra hoàn tất!');
+ console.log('='.repeat(60) + '\n');
+
+ } catch (error) {
+ console.error('❌ Lỗi khi kiểm tra dữ liệu:', error);
+ throw error;
+ } finally {
+ await sequelize.close();
+ console.log('🔌 Đã đóng kết nối database');
+ }
+}
+
+// Setup associations
+School.hasMany(Class, { foreignKey: 'school_id', as: 'classes' });
+Class.belongsTo(School, { foreignKey: 'school_id', as: 'school' });
+
+// Chạy script
+if (require.main === module) {
+ verifyImportedData()
+ .then(() => {
+ console.log('\n🎉 Script hoàn thành!');
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('\n💥 Script thất bại:', error);
+ process.exit(1);
+ });
+}
+
+module.exports = { verifyImportedData };
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..892adc8
--- /dev/null
+++ b/server.js
@@ -0,0 +1,132 @@
+const { app, initializeApp } = require('./app');
+const { closeConnection } = require('./config/database');
+const { closeRedisConnection } = require('./config/redis');
+const { closeQueues } = require('./config/bullmq');
+const config = require('./config/config.json');
+
+const PORT = config.server.port || 3000;
+const HOST = '0.0.0.0';
+
+let server;
+
+/**
+ * Start Server
+ */
+const startServer = async () => {
+ try {
+ // Initialize application (database, models, etc.)
+ await initializeApp();
+
+ // Start Express server
+ server = app.listen(PORT, HOST, () => {
+ console.log('');
+ console.log('╔════════════════════════════════════════════════════════╗');
+ console.log('║ 🚀 Sena School Management API Server Started ║');
+ console.log('╠════════════════════════════════════════════════════════╣');
+ console.log(`║ Environment: ${config.server.env.padEnd(38)} ║`);
+ console.log(`║ Server URL: http://${HOST}:${PORT}${' '.repeat(30 - HOST.length - PORT.toString().length)} ║`);
+ console.log(`║ API Base: http://${HOST}:${PORT}/api${' '.repeat(25 - HOST.length - PORT.toString().length)} ║`);
+ console.log(`║ Health: http://${HOST}:${PORT}/health${' '.repeat(22 - HOST.length - PORT.toString().length)} ║`);
+ console.log('╠════════════════════════════════════════════════════════╣');
+ console.log('║ 📊 Services Status: ║');
+ console.log('║ ✅ MySQL Database Connected (senaai.tech:11001) ║');
+ console.log('║ ✅ Redis Sentinel Connected (senaai.tech) ║');
+ console.log('║ ✅ BullMQ Queues Initialized ║');
+ console.log('╚════════════════════════════════════════════════════════╝');
+ console.log('');
+ console.log('📝 Available Endpoints:');
+ console.log(` GET /health - Health check`);
+ console.log(` GET /api - API information`);
+ console.log(` GET /api/schools - List schools`);
+ console.log(` POST /api/schools - Create school`);
+ console.log(` GET /api/queues/status - Queue metrics`);
+ console.log(` GET /api/cache/stats - Cache statistics`);
+ console.log('');
+ console.log('🔧 To start the BullMQ worker:');
+ console.log(' node workers/databaseWriteWorker.js');
+ console.log('');
+ });
+
+ // Handle server errors
+ server.on('error', (error) => {
+ console.error('❌ Server error:', error.message);
+ process.exit(1);
+ });
+
+ } catch (error) {
+ console.error('❌ Failed to start server:', error.message);
+ process.exit(1);
+ }
+};
+
+/**
+ * Graceful Shutdown
+ */
+const gracefulShutdown = async (signal) => {
+ console.log('');
+ console.log(`⚠️ ${signal} received. Starting graceful shutdown...`);
+
+ // Stop accepting new connections
+ if (server) {
+ server.close(async () => {
+ console.log('✅ HTTP server closed');
+
+ try {
+ // Close database connections
+ await closeConnection();
+
+ // Close Redis connection
+ await closeRedisConnection();
+
+ // Close BullMQ queues
+ await closeQueues();
+
+ console.log('✅ All connections closed gracefully');
+ process.exit(0);
+ } catch (error) {
+ console.error('❌ Error during shutdown:', error.message);
+ process.exit(1);
+ }
+ });
+
+ // Force close after 10 seconds
+ setTimeout(() => {
+ console.error('⚠️ Forcing shutdown after timeout');
+ process.exit(1);
+ }, 10000);
+ } else {
+ process.exit(0);
+ }
+};
+
+/**
+ * Handle Uncaught Exceptions
+ */
+process.on('uncaughtException', (error) => {
+ console.error('💥 UNCAUGHT EXCEPTION! Shutting down...');
+ console.error(error.name, error.message);
+ console.error(error.stack);
+ process.exit(1);
+});
+
+/**
+ * Handle Unhandled Promise Rejections
+ */
+process.on('unhandledRejection', (error) => {
+ console.error('💥 UNHANDLED REJECTION! Shutting down...');
+ console.error(error.name, error.message);
+ gracefulShutdown('UNHANDLED_REJECTION');
+});
+
+/**
+ * Handle Termination Signals
+ */
+process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
+process.on('SIGINT', () => gracefulShutdown('SIGINT'));
+
+/**
+ * Start the server
+ */
+startServer();
+
+module.exports = { server };
diff --git a/services/teacherProfileService.js b/services/teacherProfileService.js
new file mode 100644
index 0000000..e4affae
--- /dev/null
+++ b/services/teacherProfileService.js
@@ -0,0 +1,329 @@
+const { UsersAuth, UserProfile, TeacherDetail, School } = require('../models');
+const bcrypt = require('bcrypt');
+const { sequelize } = require('../config/database');
+
+/**
+ * Teacher Profile Service
+ * Xử lý việc tạo user_profile, users_auth cho teacher
+ */
+class TeacherProfileService {
+ /**
+ * Tạo full user cho teacher mới (users_auth + user_profile + teacher_detail)
+ * @param {Object} teacherData - Dữ liệu teacher
+ * @returns {Promise