update
This commit is contained in:
735
CONTENT_MANAGEMENT_GUIDE.md
Normal file
735
CONTENT_MANAGEMENT_GUIDE.md
Normal file
@@ -0,0 +1,735 @@
|
||||
# 📚 Hướng Dẫn Quản Lý Nội Dung Giảng Dạy và Game
|
||||
|
||||
## Tổng Quan Kiến Trúc
|
||||
|
||||
Hệ thống nội dung của bạn được xây dựng theo mô hình phân cấp:
|
||||
|
||||
```
|
||||
Subject (Môn học/Giáo trình)
|
||||
└─ Chapter (Chương)
|
||||
└─ Lesson (Bài học)
|
||||
├─ JSON Content → Game Engine render
|
||||
└─ URL Content → Video/PDF player
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Cấu Trúc Models
|
||||
|
||||
### 1. Subject (Môn học)
|
||||
**File**: `models/Subject.js`
|
||||
**Table**: `subjects`
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: UUID,
|
||||
subject_code: 'MATH_G1', // Mã môn học (unique)
|
||||
subject_name: 'Toán lớp 1', // Tên tiếng Việt
|
||||
subject_name_en: 'Math Grade 1', // Tên tiếng Anh
|
||||
description: TEXT, // Mô tả
|
||||
is_active: true, // Đang hoạt động
|
||||
is_premium: false, // Nội dung premium
|
||||
is_training: false, // Đào tạo nhân sự
|
||||
is_public: false, // Tự học công khai
|
||||
required_role: 'student', // Role yêu cầu
|
||||
min_subscription_tier: 'basic' // Gói tối thiểu
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Chapter (Chương học)
|
||||
**File**: `models/Chapter.js`
|
||||
**Table**: `chapters`
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: UUID,
|
||||
subject_id: UUID, // FK → subjects
|
||||
chapter_number: 1, // Số thứ tự chương
|
||||
chapter_title: 'Số và chữ số', // Tiêu đề
|
||||
chapter_description: TEXT, // Mô tả
|
||||
duration_minutes: 180, // Thời lượng (phút)
|
||||
is_published: true, // Đã xuất bản
|
||||
display_order: 1 // Thứ tự hiển thị
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Lesson (Bài học)
|
||||
**File**: `models/Lesson.js`
|
||||
**Table**: `lessons`
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: UUID,
|
||||
chapter_id: UUID, // FK → chapters
|
||||
lesson_number: 1, // Số thứ tự bài học
|
||||
lesson_title: 'Đếm từ 1 đến 5', // Tiêu đề
|
||||
lesson_type: 'json_content', // hoặc 'url_content'
|
||||
lesson_description: TEXT,
|
||||
|
||||
// Dạng 1: JSON Content (tương tác)
|
||||
content_json: {
|
||||
type: 'counting_quiz', // PHẢI khớp với Game.type
|
||||
questions: [...],
|
||||
instructions: '...',
|
||||
pass_score: 70
|
||||
},
|
||||
|
||||
// Dạng 2: URL Content (video/PDF/link)
|
||||
content_url: 'https://youtube.com/...',
|
||||
content_type: 'youtube', // video, audio, pdf, external_link
|
||||
|
||||
duration_minutes: 15,
|
||||
is_published: true,
|
||||
is_free: true, // Học thử miễn phí
|
||||
display_order: 1,
|
||||
thumbnail_url: '...'
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Game (Game Engine/Template)
|
||||
**File**: `models/Game.js`
|
||||
**Table**: `games`
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: UUID,
|
||||
title: 'Trò chơi đếm số',
|
||||
description: TEXT,
|
||||
url: 'https://games.senaai.tech/counting-game/', // HTML5/Unity WebGL
|
||||
thumbnail: '...',
|
||||
type: 'counting_quiz', // PHẢI khớp với Lesson.content_json.type
|
||||
config: {
|
||||
engine: 'phaser3',
|
||||
features: ['sound', 'animation'],
|
||||
controls: ['touch', 'mouse'],
|
||||
max_time: 300
|
||||
},
|
||||
is_active: true,
|
||||
is_premium: false,
|
||||
min_grade: 1, // Cấp lớp tối thiểu
|
||||
max_grade: 3, // Cấp lớp tối đa
|
||||
difficulty_level: 'easy', // easy, medium, hard
|
||||
play_count: 0,
|
||||
rating: 4.5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Cách 1: Thêm Nội Dung Giảng Dạy
|
||||
|
||||
### Bước 1: Tạo Subject (Môn học)
|
||||
|
||||
```javascript
|
||||
const { Subject, Chapter, Lesson } = require('./models');
|
||||
|
||||
// Tạo môn Toán lớp 1
|
||||
const mathSubject = await Subject.create({
|
||||
subject_code: 'MATH_G1',
|
||||
subject_name: 'Toán lớp 1',
|
||||
subject_name_en: 'Math Grade 1',
|
||||
description: 'Chương trình Toán học lớp 1 theo SGK mới',
|
||||
is_active: true,
|
||||
is_premium: false,
|
||||
is_public: true,
|
||||
min_subscription_tier: 'basic'
|
||||
});
|
||||
```
|
||||
|
||||
### Bước 2: Tạo Chapter (Chương)
|
||||
|
||||
```javascript
|
||||
const chapter1 = await Chapter.create({
|
||||
subject_id: mathSubject.id,
|
||||
chapter_number: 1,
|
||||
chapter_title: 'Số và chữ số',
|
||||
chapter_description: 'Làm quen với các số từ 1 đến 10',
|
||||
duration_minutes: 180,
|
||||
is_published: true,
|
||||
display_order: 1
|
||||
});
|
||||
|
||||
const chapter2 = await Chapter.create({
|
||||
subject_id: mathSubject.id,
|
||||
chapter_number: 2,
|
||||
chapter_title: 'Phép cộng trong phạm vi 10',
|
||||
chapter_description: 'Học cộng các số từ 1 đến 10',
|
||||
duration_minutes: 240,
|
||||
is_published: true,
|
||||
display_order: 2
|
||||
});
|
||||
```
|
||||
|
||||
### Bước 3a: Tạo Lesson với JSON Content (Tương tác)
|
||||
|
||||
```javascript
|
||||
const lesson1 = await Lesson.create({
|
||||
chapter_id: chapter1.id,
|
||||
lesson_number: 1,
|
||||
lesson_title: 'Đếm từ 1 đến 5',
|
||||
lesson_type: 'json_content',
|
||||
lesson_description: 'Học đếm các số từ 1 đến 5 qua trò chơi',
|
||||
content_json: {
|
||||
type: 'counting_quiz', // Khớp với Game có type='counting_quiz'
|
||||
theme: 'fruits',
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
question: 'Có bao nhiêu quả táo?',
|
||||
image: 'https://cdn.senaai.tech/images/apples-3.png',
|
||||
correct_answer: 3,
|
||||
options: [2, 3, 4, 5]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
question: 'Có bao nhiêu quả cam?',
|
||||
image: 'https://cdn.senaai.tech/images/oranges-5.png',
|
||||
correct_answer: 5,
|
||||
options: [3, 4, 5, 6]
|
||||
}
|
||||
],
|
||||
instructions: 'Nhìn vào hình và đếm số lượng vật, sau đó chọn đáp án đúng',
|
||||
pass_score: 70,
|
||||
max_attempts: 3,
|
||||
show_hints: true
|
||||
},
|
||||
duration_minutes: 15,
|
||||
is_published: true,
|
||||
is_free: true,
|
||||
display_order: 1,
|
||||
thumbnail_url: 'https://cdn.senaai.tech/thumbnails/lesson-counting.jpg'
|
||||
});
|
||||
```
|
||||
|
||||
### Bước 3b: Tạo Lesson với URL Content (Video/PDF)
|
||||
|
||||
```javascript
|
||||
// Video YouTube
|
||||
const lesson2 = await Lesson.create({
|
||||
chapter_id: chapter1.id,
|
||||
lesson_number: 2,
|
||||
lesson_title: 'Video: Hướng dẫn đếm số',
|
||||
lesson_type: 'url_content',
|
||||
lesson_description: 'Video hướng dẫn cách đếm số từ 1 đến 10',
|
||||
content_url: 'https://www.youtube.com/watch?v=abc123xyz',
|
||||
content_type: 'youtube',
|
||||
duration_minutes: 10,
|
||||
is_published: true,
|
||||
is_free: false,
|
||||
display_order: 2
|
||||
});
|
||||
|
||||
// PDF Document
|
||||
const lesson3 = await Lesson.create({
|
||||
chapter_id: chapter1.id,
|
||||
lesson_number: 3,
|
||||
lesson_title: 'Tài liệu: Bảng số từ 1-10',
|
||||
lesson_type: 'url_content',
|
||||
content_url: 'https://cdn.senaai.tech/docs/number-chart-1-10.pdf',
|
||||
content_type: 'pdf',
|
||||
duration_minutes: 5,
|
||||
is_published: true,
|
||||
is_free: true,
|
||||
display_order: 3
|
||||
});
|
||||
|
||||
// Audio
|
||||
const lesson4 = await Lesson.create({
|
||||
chapter_id: chapter1.id,
|
||||
lesson_number: 4,
|
||||
lesson_title: 'Nghe: Phát âm các số',
|
||||
lesson_type: 'url_content',
|
||||
content_url: 'https://cdn.senaai.tech/audio/numbers-pronunciation.mp3',
|
||||
content_type: 'audio',
|
||||
duration_minutes: 8,
|
||||
is_published: true,
|
||||
display_order: 4
|
||||
});
|
||||
```
|
||||
|
||||
### Bước 3c: Các loại Content JSON khác
|
||||
|
||||
```javascript
|
||||
// Quiz trắc nghiệm
|
||||
const quizLesson = await Lesson.create({
|
||||
chapter_id: chapter2.id,
|
||||
lesson_number: 1,
|
||||
lesson_title: 'Bài kiểm tra: Phép cộng',
|
||||
lesson_type: 'json_content',
|
||||
content_json: {
|
||||
type: 'multiple_choice_quiz',
|
||||
time_limit: 600, // 10 phút
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
question: '2 + 3 = ?',
|
||||
options: ['4', '5', '6', '7'],
|
||||
correct_answer: '5',
|
||||
explanation: 'Hai cộng ba bằng năm'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
question: '4 + 5 = ?',
|
||||
options: ['7', '8', '9', '10'],
|
||||
correct_answer: '9'
|
||||
}
|
||||
],
|
||||
pass_score: 80
|
||||
},
|
||||
duration_minutes: 10,
|
||||
is_published: true
|
||||
});
|
||||
|
||||
// Bài tập tương tác
|
||||
const interactiveLesson = await Lesson.create({
|
||||
chapter_id: chapter2.id,
|
||||
lesson_number: 2,
|
||||
lesson_title: 'Thực hành: Giải toán cộng',
|
||||
lesson_type: 'json_content',
|
||||
content_json: {
|
||||
type: 'math_practice',
|
||||
difficulty: 'easy',
|
||||
operations: ['addition'],
|
||||
range: { min: 1, max: 10 },
|
||||
question_count: 20,
|
||||
show_solution_steps: true,
|
||||
allow_calculator: false
|
||||
},
|
||||
duration_minutes: 20,
|
||||
is_published: true
|
||||
});
|
||||
|
||||
// Assignment (Bài tập về nhà)
|
||||
const assignmentLesson = await Lesson.create({
|
||||
chapter_id: chapter2.id,
|
||||
lesson_number: 3,
|
||||
lesson_title: 'Bài tập về nhà: Tuần 1',
|
||||
lesson_type: 'json_content',
|
||||
content_json: {
|
||||
type: 'assignment',
|
||||
deadline_days: 7,
|
||||
tasks: [
|
||||
{
|
||||
task_id: 1,
|
||||
title: 'Làm bài tập SGK trang 15',
|
||||
description: 'Hoàn thành các bài từ 1 đến 5',
|
||||
points: 10
|
||||
},
|
||||
{
|
||||
task_id: 2,
|
||||
title: 'Vẽ 5 quả táo và đếm',
|
||||
description: 'Vẽ hình và viết số',
|
||||
points: 5,
|
||||
requires_upload: true
|
||||
}
|
||||
],
|
||||
total_points: 15,
|
||||
submission_type: 'photo' // photo, pdf, text
|
||||
},
|
||||
duration_minutes: 30,
|
||||
is_published: true
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Cách 2: Thêm Game Engine
|
||||
|
||||
### Tạo Game Template
|
||||
|
||||
```javascript
|
||||
const { Game } = require('./models');
|
||||
|
||||
// Game 1: Đếm số
|
||||
const countingGame = await Game.create({
|
||||
title: 'Trò chơi đếm số',
|
||||
description: 'Game tương tác giúp trẻ học đếm các vật thể',
|
||||
url: 'https://games.senaai.tech/counting-game/index.html',
|
||||
thumbnail: 'https://cdn.senaai.tech/game-thumbs/counting.jpg',
|
||||
type: 'counting_quiz', // Khớp với Lesson.content_json.type
|
||||
config: {
|
||||
engine: 'phaser3',
|
||||
version: '1.0.0',
|
||||
features: ['sound', 'animation', 'reward_stars'],
|
||||
controls: ['touch', 'mouse', 'keyboard'],
|
||||
responsive: true,
|
||||
max_time_per_question: 60,
|
||||
hints_enabled: true,
|
||||
save_progress: true
|
||||
},
|
||||
is_active: true,
|
||||
is_premium: false,
|
||||
min_grade: 1,
|
||||
max_grade: 2,
|
||||
difficulty_level: 'easy',
|
||||
play_count: 0,
|
||||
rating: 0,
|
||||
display_order: 1
|
||||
});
|
||||
|
||||
// Game 2: Quiz trắc nghiệm
|
||||
const quizGame = await Game.create({
|
||||
title: 'Trả lời nhanh',
|
||||
description: 'Trò chơi trắc nghiệm với giới hạn thời gian',
|
||||
url: 'https://games.senaai.tech/quiz-game/index.html',
|
||||
thumbnail: 'https://cdn.senaai.tech/game-thumbs/quiz.jpg',
|
||||
type: 'multiple_choice_quiz',
|
||||
config: {
|
||||
engine: 'react',
|
||||
features: ['timer', 'leaderboard', 'achievements'],
|
||||
sound_effects: true,
|
||||
show_correct_answer: true,
|
||||
retry_allowed: true
|
||||
},
|
||||
is_active: true,
|
||||
is_premium: false,
|
||||
min_grade: 1,
|
||||
max_grade: 12,
|
||||
difficulty_level: 'medium',
|
||||
display_order: 2
|
||||
});
|
||||
|
||||
// Game 3: Thực hành Toán
|
||||
const mathPracticeGame = await Game.create({
|
||||
title: 'Luyện tập Toán',
|
||||
description: 'Game luyện toán với nhiều cấp độ khó',
|
||||
url: 'https://games.senaai.tech/math-practice/index.html',
|
||||
thumbnail: 'https://cdn.senaai.tech/game-thumbs/math.jpg',
|
||||
type: 'math_practice',
|
||||
config: {
|
||||
engine: 'unity_webgl',
|
||||
features: ['adaptive_difficulty', 'step_by_step_solution', 'scratch_pad'],
|
||||
controls: ['touch', 'mouse'],
|
||||
performance_tracking: true
|
||||
},
|
||||
is_active: true,
|
||||
is_premium: true,
|
||||
min_grade: 1,
|
||||
max_grade: 6,
|
||||
difficulty_level: 'medium',
|
||||
display_order: 3
|
||||
});
|
||||
|
||||
// Game 4: Word Puzzle
|
||||
const wordPuzzleGame = await Game.create({
|
||||
title: 'Ghép chữ',
|
||||
description: 'Trò chơi ghép chữ và học từ vựng',
|
||||
url: 'https://games.senaai.tech/word-puzzle/index.html',
|
||||
thumbnail: 'https://cdn.senaai.tech/game-thumbs/word.jpg',
|
||||
type: 'word_puzzle',
|
||||
config: {
|
||||
engine: 'phaser3',
|
||||
features: ['drag_drop', 'text_to_speech', 'pronunciation'],
|
||||
languages: ['vi', 'en'],
|
||||
difficulty_levels: ['easy', 'medium', 'hard']
|
||||
},
|
||||
is_active: true,
|
||||
is_premium: false,
|
||||
min_grade: 1,
|
||||
max_grade: 5,
|
||||
difficulty_level: 'easy',
|
||||
display_order: 4
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Luồng Hoạt Động
|
||||
|
||||
### Frontend Flow
|
||||
|
||||
```javascript
|
||||
// 1. Học viên chọn môn học
|
||||
GET /api/subjects/:subject_id
|
||||
→ Trả về thông tin Subject
|
||||
|
||||
// 2. Xem danh sách chương
|
||||
GET /api/subjects/:subject_id/chapters
|
||||
→ Trả về danh sách Chapter
|
||||
|
||||
// 3. Xem danh sách bài học trong chương
|
||||
GET /api/chapters/:chapter_id/lessons
|
||||
→ Trả về danh sách Lesson
|
||||
|
||||
// 4. Học một bài cụ thể
|
||||
GET /api/lessons/:lesson_id
|
||||
→ Trả về chi tiết Lesson
|
||||
|
||||
// 5a. Nếu lesson_type = 'json_content'
|
||||
const lessonType = lesson.content_json.type; // vd: 'counting_quiz'
|
||||
|
||||
// Tìm game engine phù hợp
|
||||
GET /api/games?type=counting_quiz
|
||||
→ Trả về Game có type khớp
|
||||
|
||||
// Load game và truyền content vào
|
||||
<iframe
|
||||
src={game.url}
|
||||
data-content={JSON.stringify(lesson.content_json)}
|
||||
/>
|
||||
|
||||
// 5b. Nếu lesson_type = 'url_content'
|
||||
if (lesson.content_type === 'youtube') {
|
||||
<YouTubePlayer url={lesson.content_url} />
|
||||
} else if (lesson.content_type === 'pdf') {
|
||||
<PDFViewer url={lesson.content_url} />
|
||||
} else if (lesson.content_type === 'audio') {
|
||||
<AudioPlayer url={lesson.content_url} />
|
||||
}
|
||||
```
|
||||
|
||||
### Game Engine Integration
|
||||
|
||||
```javascript
|
||||
// Trong game engine (ví dụ: counting-game/index.html)
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'LOAD_CONTENT') {
|
||||
const content = event.data.content; // Lesson.content_json
|
||||
|
||||
// Render game với nội dung từ lesson
|
||||
renderGame({
|
||||
questions: content.questions,
|
||||
theme: content.theme,
|
||||
instructions: content.instructions,
|
||||
passScore: content.pass_score
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Khi học viên hoàn thành
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_COMPLETE',
|
||||
score: 85,
|
||||
time_spent: 300,
|
||||
answers: [...]
|
||||
}, '*');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 API Endpoints Cần Thiết
|
||||
|
||||
### Subject Routes (Đã có: `routes/subjectRoutes.js`)
|
||||
```
|
||||
GET /api/subjects - Danh sách môn học
|
||||
GET /api/subjects/:id - Chi tiết môn học
|
||||
POST /api/subjects - Tạo môn học mới
|
||||
PUT /api/subjects/:id - Cập nhật môn học
|
||||
DELETE /api/subjects/:id - Xóa môn học
|
||||
```
|
||||
|
||||
### Chapter Routes (Đã có: `routes/chapterRoutes.js`)
|
||||
```
|
||||
GET /api/chapters - Danh sách chương
|
||||
GET /api/chapters/:id - Chi tiết chương
|
||||
GET /api/subjects/:id/chapters - Chương của môn học
|
||||
POST /api/chapters - Tạo chương mới
|
||||
PUT /api/chapters/:id - Cập nhật chương
|
||||
DELETE /api/chapters/:id - Xóa chương
|
||||
```
|
||||
|
||||
### Lesson Routes (CẦN TẠO)
|
||||
```
|
||||
GET /api/lessons - Danh sách bài học
|
||||
GET /api/lessons/:id - Chi tiết bài học
|
||||
GET /api/chapters/:id/lessons - Bài học của chương
|
||||
POST /api/lessons - Tạo bài học mới
|
||||
PUT /api/lessons/:id - Cập nhật bài học
|
||||
DELETE /api/lessons/:id - Xóa bài học
|
||||
POST /api/lessons/:id/complete - Đánh dấu hoàn thành
|
||||
```
|
||||
|
||||
### Game Routes (Đã có: `routes/gameRoutes.js`)
|
||||
```
|
||||
GET /api/games - Danh sách game
|
||||
GET /api/games/:id - Chi tiết game
|
||||
GET /api/games?type=xxx - Lọc theo type
|
||||
POST /api/games - Tạo game mới
|
||||
PUT /api/games/:id - Cập nhật game
|
||||
DELETE /api/games/:id - Xóa game
|
||||
POST /api/games/:id/play - Tăng play_count
|
||||
POST /api/games/:id/rate - Đánh giá game
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist Triển Khai
|
||||
|
||||
### Bước 1: Tạo Lesson Routes & Controller
|
||||
- [ ] Tạo `routes/lessonRoutes.js`
|
||||
- [ ] Tạo `controllers/lessonController.js`
|
||||
- [ ] Đăng ký route trong `app.js`
|
||||
|
||||
### Bước 2: Tạo Sample Data
|
||||
- [ ] Tạo script `scripts/seed-sample-content.js`
|
||||
- [ ] Tạo Subject mẫu (Toán, Tiếng Việt, Tiếng Anh)
|
||||
- [ ] Tạo Chapter mẫu (3-5 chương mỗi môn)
|
||||
- [ ] Tạo Lesson mẫu (5-10 bài mỗi chương)
|
||||
- [ ] Tạo Game template mẫu (3-5 game)
|
||||
|
||||
### Bước 3: Update Relations
|
||||
```javascript
|
||||
// Trong models/index.js
|
||||
Subject.hasMany(Chapter, { foreignKey: 'subject_id' });
|
||||
Chapter.belongsTo(Subject, { foreignKey: 'subject_id' });
|
||||
|
||||
Chapter.hasMany(Lesson, { foreignKey: 'chapter_id' });
|
||||
Lesson.belongsTo(Chapter, { foreignKey: 'chapter_id' });
|
||||
```
|
||||
|
||||
### Bước 4: Thêm Swagger Documentation
|
||||
- [ ] Thêm Swagger cho Lesson routes
|
||||
- [ ] Cập nhật schemas trong `config/swagger.js`
|
||||
|
||||
### Bước 5: Test
|
||||
- [ ] Test tạo Subject → Chapter → Lesson
|
||||
- [ ] Test query lessons theo chapter
|
||||
- [ ] Test match Game với Lesson.content_json.type
|
||||
- [ ] Test các loại content khác nhau
|
||||
|
||||
---
|
||||
|
||||
## 💡 Best Practices
|
||||
|
||||
### 1. Lesson Type Naming Convention
|
||||
```javascript
|
||||
// Đặt tên theo format: <category>_<action>
|
||||
'counting_quiz' // Đếm số - Quiz
|
||||
'multiple_choice_quiz' // Trắc nghiệm
|
||||
'math_practice' // Luyện toán
|
||||
'word_puzzle' // Ghép chữ
|
||||
'reading_comprehension' // Đọc hiểu
|
||||
'listening_exercise' // Nghe
|
||||
'speaking_practice' // Luyện nói
|
||||
'writing_assignment' // Bài tập viết
|
||||
'project_based' // Dự án
|
||||
```
|
||||
|
||||
### 2. Content JSON Structure
|
||||
```javascript
|
||||
{
|
||||
type: 'xxx', // REQUIRED: Phải khớp với Game.type
|
||||
version: '1.0', // Version của schema
|
||||
metadata: { // Optional: Thông tin thêm
|
||||
author: 'Nguyễn Văn A',
|
||||
created_at: '2026-01-19',
|
||||
tags: ['counting', 'basic', 'grade1']
|
||||
},
|
||||
config: {}, // Cấu hình riêng của lesson
|
||||
data: {} // Dữ liệu chính (questions, tasks, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Game Config Structure
|
||||
```javascript
|
||||
{
|
||||
engine: 'phaser3', // Game engine sử dụng
|
||||
version: '1.0.0',
|
||||
features: [], // Các tính năng: sound, animation, etc.
|
||||
controls: [], // touch, mouse, keyboard
|
||||
responsive: true,
|
||||
settings: {
|
||||
max_time: 300,
|
||||
hints_allowed: true,
|
||||
retry_count: 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Error Handling
|
||||
```javascript
|
||||
// Khi student học lesson
|
||||
if (lesson.lesson_type === 'json_content') {
|
||||
const lessonType = lesson.content_json.type;
|
||||
const game = await Game.findOne({
|
||||
where: {
|
||||
type: lessonType,
|
||||
is_active: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!game) {
|
||||
// Fallback: Hiển thị content JSON dạng text/list
|
||||
console.warn(`No game found for type: ${lessonType}`);
|
||||
return renderStaticContent(lesson.content_json);
|
||||
}
|
||||
|
||||
return renderGameWithContent(game, lesson.content_json);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start Script
|
||||
|
||||
Chạy script này để tạo sample data:
|
||||
|
||||
```javascript
|
||||
// scripts/seed-sample-content.js
|
||||
const { Subject, Chapter, Lesson, Game } = require('../models');
|
||||
|
||||
async function seedSampleContent() {
|
||||
// 1. Tạo Subject
|
||||
const math = await Subject.create({
|
||||
subject_code: 'MATH_G1',
|
||||
subject_name: 'Toán lớp 1',
|
||||
is_active: true,
|
||||
is_public: true
|
||||
});
|
||||
|
||||
// 2. Tạo Chapter
|
||||
const chapter = await Chapter.create({
|
||||
subject_id: math.id,
|
||||
chapter_number: 1,
|
||||
chapter_title: 'Số và chữ số',
|
||||
is_published: true
|
||||
});
|
||||
|
||||
// 3. Tạo Lesson
|
||||
const lesson = await Lesson.create({
|
||||
chapter_id: chapter.id,
|
||||
lesson_number: 1,
|
||||
lesson_title: 'Đếm từ 1 đến 5',
|
||||
lesson_type: 'json_content',
|
||||
content_json: {
|
||||
type: 'counting_quiz',
|
||||
questions: [
|
||||
{
|
||||
question: 'Có bao nhiêu quả táo?',
|
||||
image: '/images/apples-3.png',
|
||||
answer: 3,
|
||||
options: [2, 3, 4, 5]
|
||||
}
|
||||
]
|
||||
},
|
||||
is_published: true,
|
||||
is_free: true
|
||||
});
|
||||
|
||||
// 4. Tạo Game
|
||||
const game = await Game.create({
|
||||
title: 'Trò chơi đếm số',
|
||||
url: 'https://games.senaai.tech/counting/',
|
||||
type: 'counting_quiz',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
console.log('✅ Sample content created successfully!');
|
||||
}
|
||||
|
||||
seedSampleContent();
|
||||
```
|
||||
|
||||
Chạy: `node scripts/seed-sample-content.js`
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
Nếu cần hỗ trợ thêm:
|
||||
1. Tạo Lesson Routes & Controller
|
||||
2. Tạo script seed data mẫu
|
||||
3. Cập nhật Swagger documentation
|
||||
4. Tạo frontend component để render lesson
|
||||
|
||||
Hãy cho tôi biết bạn muốn tôi implement phần nào! 🚀
|
||||
278
HYBRID_ROLE_ARCHITECTURE.md
Normal file
278
HYBRID_ROLE_ARCHITECTURE.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# Hybrid Role Architecture - Hướng dẫn sử dụng
|
||||
|
||||
## 📋 Tổng quan
|
||||
|
||||
Hệ thống sử dụng **Hybrid Role Architecture** để tối ưu hiệu năng:
|
||||
|
||||
- **80-90% users (học sinh, phụ huynh)**: Dùng `primary_role_info` trong `UserProfile` → **NHANH** (2 JOINs)
|
||||
- **10-20% users (giáo viên, quản lý)**: Dùng `UserAssignment` → **Linh hoạt** (5 JOINs)
|
||||
|
||||
## 🗂️ Cấu trúc dữ liệu
|
||||
|
||||
### UserProfile.primary_role_info (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"role_id": "uuid",
|
||||
"role_code": "student",
|
||||
"role_name": "Học sinh",
|
||||
"school": {
|
||||
"id": "uuid",
|
||||
"name": "SENA Hà Nội"
|
||||
},
|
||||
"class": {
|
||||
"id": "uuid",
|
||||
"name": "K1A"
|
||||
},
|
||||
"grade": {
|
||||
"id": "uuid",
|
||||
"name": "Khối 1"
|
||||
},
|
||||
"student_code": "HS001",
|
||||
"enrollment_date": "2024-09-01",
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 Migration & Setup
|
||||
|
||||
### 1. Thêm column vào database
|
||||
|
||||
```bash
|
||||
node scripts/add-primary-role-info.js
|
||||
```
|
||||
|
||||
### 2. Populate dữ liệu cho học sinh hiện có
|
||||
|
||||
```bash
|
||||
node scripts/populate-primary-role-info.js
|
||||
```
|
||||
|
||||
### 3. Kiểm tra kết quả
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
user_id,
|
||||
full_name,
|
||||
JSON_EXTRACT(primary_role_info, '$.role_code') as role,
|
||||
JSON_EXTRACT(primary_role_info, '$.school.name') as school
|
||||
FROM user_profiles
|
||||
WHERE primary_role_info IS NOT NULL
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
## 📝 API Response mới
|
||||
|
||||
### Login Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Đăng nhập thành công",
|
||||
"data": {
|
||||
"token": "eyJhbGc...",
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"username": "student001",
|
||||
"email": "student@example.com",
|
||||
"profile": {
|
||||
"full_name": "Nguyễn Văn A",
|
||||
"avatar_url": "https://...",
|
||||
"primary_role_info": {
|
||||
"role_code": "student",
|
||||
"role_name": "Học sinh",
|
||||
"school": { "id": "uuid", "name": "SENA HN" },
|
||||
"class": { "id": "uuid", "name": "K1A" }
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"role_id": "uuid",
|
||||
"role_code": "student",
|
||||
"role_name": "Học sinh",
|
||||
"school": { "id": "uuid", "name": "SENA HN" },
|
||||
"class": { "id": "uuid", "name": "K1A" }
|
||||
},
|
||||
"permissions": [
|
||||
{
|
||||
"code": "view_own_grades",
|
||||
"name": "Xem điểm của mình",
|
||||
"resource": "grades",
|
||||
"action": "view"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Khi nào dùng cái nào?
|
||||
|
||||
### ✅ Dùng `primary_role_info` (Fast Path)
|
||||
|
||||
- Học sinh (`student`)
|
||||
- Phụ huynh thường (`parent`)
|
||||
- User có 1 role cố định
|
||||
- Không thay đổi school/class thường xuyên
|
||||
|
||||
**Cách tạo:**
|
||||
```javascript
|
||||
await UserProfile.create({
|
||||
user_id: userId,
|
||||
full_name: "Nguyễn Văn A",
|
||||
primary_role_info: {
|
||||
role_id: studentRoleId,
|
||||
role_code: "student",
|
||||
role_name: "Học sinh",
|
||||
school: { id: schoolId, name: "SENA HN" },
|
||||
class: { id: classId, name: "K1A" },
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### ✅ Dùng `UserAssignment` (Flexible Path)
|
||||
|
||||
- Giáo viên dạy nhiều trường
|
||||
- Quản lý nhiều center
|
||||
- User có nhiều role đồng thời
|
||||
- Cần theo dõi thời gian hiệu lực role
|
||||
|
||||
**Cách tạo:**
|
||||
```javascript
|
||||
// Set primary_role_info = null
|
||||
await UserProfile.create({
|
||||
user_id: userId,
|
||||
full_name: "Nguyễn Thị B",
|
||||
primary_role_info: null, // ← Để null
|
||||
});
|
||||
|
||||
// Tạo assignments
|
||||
await UserAssignment.bulkCreate([
|
||||
{
|
||||
user_id: userId,
|
||||
role_id: teacherRoleId,
|
||||
school_id: school1Id,
|
||||
is_primary: true,
|
||||
},
|
||||
{
|
||||
user_id: userId,
|
||||
role_id: teacherRoleId,
|
||||
school_id: school2Id,
|
||||
is_primary: false,
|
||||
}
|
||||
]);
|
||||
```
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
### Before (Tất cả user dùng UserAssignment)
|
||||
- Login query: **6 JOINs**
|
||||
- Avg response time: **50-80ms** (without cache)
|
||||
|
||||
### After (Hybrid approach)
|
||||
- **Student login**: **2 JOINs** → **20-30ms** ✅ (-60%)
|
||||
- **Teacher login**: **5 JOINs** → **50-70ms** (tương tự)
|
||||
|
||||
### Với Redis Cache
|
||||
- **Student**: **< 1ms** (cache hit)
|
||||
- **Teacher**: **< 2ms** (cache hit)
|
||||
|
||||
## 🔄 Cập nhật primary_role_info
|
||||
|
||||
Khi học sinh chuyển lớp hoặc thay đổi thông tin:
|
||||
|
||||
```javascript
|
||||
// Option 1: Update trực tiếp
|
||||
await userProfile.update({
|
||||
primary_role_info: {
|
||||
...userProfile.primary_role_info,
|
||||
class: { id: newClassId, name: "K2A" }
|
||||
}
|
||||
});
|
||||
|
||||
// Option 2: Rebuild từ StudentDetail
|
||||
const student = await StudentDetail.findOne({
|
||||
where: { user_id: userId },
|
||||
include: [
|
||||
{ model: Class, as: 'currentClass', include: [School] },
|
||||
]
|
||||
});
|
||||
|
||||
await userProfile.update({
|
||||
primary_role_info: {
|
||||
role_id: studentRole.id,
|
||||
role_code: 'student',
|
||||
role_name: 'Học sinh',
|
||||
school: {
|
||||
id: student.currentClass.school.id,
|
||||
name: student.currentClass.school.school_name
|
||||
},
|
||||
class: {
|
||||
id: student.currentClass.id,
|
||||
name: student.currentClass.class_name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Don't forget to invalidate cache!
|
||||
await redis.del(`user:${userId}:permissions`);
|
||||
```
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
1. **Luôn check `primary_role_info` trước** khi query `UserAssignment`
|
||||
2. **Cache permissions** trong Redis với TTL 1-24h
|
||||
3. **Invalidate cache** khi update role/permission
|
||||
4. **Monitor slow queries** để phát hiện N+1 query
|
||||
5. **Index properly**: `user_id`, `role_id`, `school_id` trong `user_assignments`
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
```sql
|
||||
-- Thống kê users theo role strategy
|
||||
SELECT
|
||||
CASE
|
||||
WHEN primary_role_info IS NOT NULL THEN 'Fast Path'
|
||||
ELSE 'Flexible Path'
|
||||
END as strategy,
|
||||
COUNT(*) as count,
|
||||
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM user_profiles), 2) as percentage
|
||||
FROM user_profiles
|
||||
GROUP BY strategy;
|
||||
|
||||
-- Expected result:
|
||||
-- Fast Path: 80-90% (students, parents)
|
||||
-- Flexible Path: 10-20% (teachers, managers)
|
||||
```
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- JWT token bây giờ bao gồm `roleCode` để validate nhanh
|
||||
- Permissions vẫn được check từ database (không tin JWT hoàn toàn)
|
||||
- Rate limiting theo role: student (100 req/min), teacher (200 req/min)
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### User không có role sau login?
|
||||
|
||||
```javascript
|
||||
// Check primary_role_info
|
||||
const profile = await UserProfile.findOne({ where: { user_id } });
|
||||
console.log(profile.primary_role_info);
|
||||
|
||||
// Check UserAssignment
|
||||
const assignments = await UserAssignment.findAll({
|
||||
where: { user_id, is_active: true }
|
||||
});
|
||||
console.log(assignments);
|
||||
```
|
||||
|
||||
### Query chậm?
|
||||
|
||||
1. Check indexes: `SHOW INDEX FROM user_profiles;`
|
||||
2. Check Redis cache hit rate
|
||||
3. Enable query logging: `SET GLOBAL general_log = 'ON';`
|
||||
|
||||
---
|
||||
|
||||
**Last updated:** January 19, 2026
|
||||
**Version:** 2.0.0
|
||||
228
SWAGGER_GUIDE.md
Normal file
228
SWAGGER_GUIDE.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Swagger API Documentation
|
||||
|
||||
## 🚀 Truy cập Swagger UI
|
||||
|
||||
Sau khi start server, truy cập:
|
||||
|
||||
```
|
||||
http://localhost:3000/api-docs
|
||||
```
|
||||
|
||||
## 📚 Swagger JSON
|
||||
|
||||
Lấy định nghĩa OpenAPI 3.0 JSON:
|
||||
|
||||
```
|
||||
http://localhost:3000/api-docs.json
|
||||
```
|
||||
|
||||
## 🔐 Authentication trong Swagger UI
|
||||
|
||||
### Bước 1: Login để lấy token
|
||||
|
||||
1. Mở endpoint `POST /api/auth/login`
|
||||
2. Click **"Try it out"**
|
||||
3. Nhập:
|
||||
```json
|
||||
{
|
||||
"username": "student001",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
4. Click **"Execute"**
|
||||
5. Copy `token` từ response
|
||||
|
||||
### Bước 2: Authorize
|
||||
|
||||
1. Click nút **"Authorize"** 🔓 ở góc trên bên phải
|
||||
2. Nhập token vào ô **bearerAuth**: `<token_vừa_copy>`
|
||||
3. Click **"Authorize"**
|
||||
4. Click **"Close"**
|
||||
|
||||
### Bước 3: Test các API cần authentication
|
||||
|
||||
Giờ bạn có thể test các endpoint như:
|
||||
- `GET /api/auth/me`
|
||||
- `POST /api/auth/logout`
|
||||
- `POST /api/auth/verify-token`
|
||||
|
||||
Token sẽ tự động được thêm vào header `Authorization: Bearer <token>`
|
||||
|
||||
---
|
||||
|
||||
## 📋 Endpoints đã document
|
||||
|
||||
### Authentication APIs
|
||||
|
||||
| Method | Endpoint | Description | Auth Required |
|
||||
|--------|----------|-------------|---------------|
|
||||
| POST | `/api/auth/login` | Đăng nhập | ❌ |
|
||||
| POST | `/api/auth/register` | Đăng ký tài khoản | ❌ |
|
||||
| POST | `/api/auth/verify-token` | Xác thực token | ✅ |
|
||||
| POST | `/api/auth/logout` | Đăng xuất | ✅ |
|
||||
| GET | `/api/auth/me` | Lấy thông tin user hiện tại | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Tính năng Swagger UI
|
||||
|
||||
- ✅ **Persist Authorization**: Token được lưu tự động sau khi authorize
|
||||
- ✅ **Display Request Duration**: Hiển thị thời gian response
|
||||
- ✅ **Filter**: Tìm kiếm endpoints nhanh
|
||||
- ✅ **Syntax Highlight**: Code highlighting với theme Monokai
|
||||
- ✅ **Try it out**: Test API trực tiếp từ UI
|
||||
|
||||
---
|
||||
|
||||
## 📖 Response Examples
|
||||
|
||||
### Login Success Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Đăng nhập thành công",
|
||||
"data": {
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"username": "student001",
|
||||
"email": "student@example.com",
|
||||
"profile": {
|
||||
"full_name": "Nguyễn Văn A",
|
||||
"avatar_url": "https://...",
|
||||
"primary_role_info": {
|
||||
"role_code": "student",
|
||||
"role_name": "Học sinh",
|
||||
"school": {
|
||||
"id": "uuid",
|
||||
"name": "SENA Hà Nội"
|
||||
},
|
||||
"class": {
|
||||
"id": "uuid",
|
||||
"name": "K1A"
|
||||
}
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"role_id": "uuid",
|
||||
"role_code": "student",
|
||||
"role_name": "Học sinh",
|
||||
"school": { "id": "uuid", "name": "SENA HN" },
|
||||
"class": { "id": "uuid", "name": "K1A" }
|
||||
},
|
||||
"permissions": [
|
||||
{
|
||||
"code": "view_own_grades",
|
||||
"name": "Xem điểm của mình",
|
||||
"resource": "grades",
|
||||
"action": "view"
|
||||
}
|
||||
],
|
||||
"last_login": "2026-01-19T10:30:00.000Z",
|
||||
"login_count": 15
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Username hoặc password không đúng",
|
||||
"attemptsLeft": 3
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Cấu hình Swagger
|
||||
|
||||
File config: `config/swagger.js`
|
||||
|
||||
### Thêm endpoint mới
|
||||
|
||||
Thêm Swagger JSDoc comment vào controller:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* @swagger
|
||||
* /api/your-endpoint:
|
||||
* get:
|
||||
* tags: [YourTag]
|
||||
* summary: Your summary
|
||||
* description: Your description
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
*/
|
||||
async yourMethod(req, res) {
|
||||
// Your code
|
||||
}
|
||||
```
|
||||
|
||||
### Thêm schema mới
|
||||
|
||||
Trong `config/swagger.js`, thêm vào `components.schemas`:
|
||||
|
||||
```javascript
|
||||
YourSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string' },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Swagger UI không hiển thị
|
||||
|
||||
1. Check server đã start: `npm run dev`
|
||||
2. Check port 3000 không bị chiếm
|
||||
3. Check console có error không
|
||||
|
||||
### Token không work sau authorize
|
||||
|
||||
1. Đảm bảo format: `Bearer <token>` (không có dấu ngoặc)
|
||||
2. Token phải valid (chưa expire sau 24h)
|
||||
3. Check response từ login có token không
|
||||
|
||||
### CSP errors trong console
|
||||
|
||||
Đã được fix trong `app.js` bằng cách thêm:
|
||||
```javascript
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "https:", "validator.swagger.io"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Best Practices
|
||||
|
||||
1. **Luôn test API trong Swagger trước khi code client**
|
||||
2. **Update documentation khi thêm/sửa endpoint**
|
||||
3. **Dùng "Try it out" để verify request/response schema**
|
||||
4. **Copy curl command từ Swagger để debug**
|
||||
5. **Export Swagger JSON để generate client SDK**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
Để thêm documentation cho các controller khác:
|
||||
|
||||
1. Mở file controller (vd: `controllers/studentController.js`)
|
||||
2. Thêm Swagger JSDoc comments
|
||||
3. Restart server
|
||||
4. Refresh Swagger UI
|
||||
|
||||
---
|
||||
|
||||
**Last updated:** January 19, 2026
|
||||
**Swagger Version:** OpenAPI 3.0.0
|
||||
39
app.js
39
app.js
@@ -5,6 +5,7 @@ const compression = require('compression');
|
||||
const morgan = require('morgan');
|
||||
require('express-async-errors'); // Handle async errors
|
||||
const config = require('./config/config.json');
|
||||
const { swaggerUi, swaggerSpec } = require('./config/swagger');
|
||||
|
||||
// Import configurations
|
||||
const { initializeDatabase } = require('./config/database');
|
||||
@@ -44,9 +45,9 @@ app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"], // Allow inline scripts for Swagger UI
|
||||
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for Swagger UI
|
||||
imgSrc: ["'self'", "data:", "https:", "validator.swagger.io"], // Allow Swagger validator
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
@@ -125,8 +126,8 @@ app.get('/api', (req, res) => {
|
||||
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: {
|
||||
endpoints: {
|
||||
auth: '/api/auth',
|
||||
schools: '/api/schools',
|
||||
classes: '/api/classes',
|
||||
academicYears: '/api/academic-years',
|
||||
@@ -143,10 +144,36 @@ app.get('/api', (req, res) => {
|
||||
chapters: '/api/chapters',
|
||||
games: '/api/games',
|
||||
},
|
||||
documentation: '/api/docs',
|
||||
documentation: '/api-docs',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Swagger API Documentation
|
||||
*/
|
||||
app.use('/api-docs', swaggerUi.serve);
|
||||
app.get('/api-docs', swaggerUi.setup(swaggerSpec, {
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: 'SENA API Documentation',
|
||||
customfavIcon: '/favicon.ico',
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
displayRequestDuration: true,
|
||||
filter: true,
|
||||
syntaxHighlight: {
|
||||
theme: 'monokai',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Swagger JSON endpoint
|
||||
*/
|
||||
app.get('/api-docs.json', (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(swaggerSpec);
|
||||
});
|
||||
|
||||
/**
|
||||
* API Routes
|
||||
*/
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"keyPrefix": "sena:"
|
||||
},
|
||||
"server": {
|
||||
"port" : 4000,
|
||||
"port" : 3000,
|
||||
"env": "production"
|
||||
},
|
||||
"cors": {
|
||||
|
||||
201
config/swagger.js
Normal file
201
config/swagger.js
Normal file
@@ -0,0 +1,201 @@
|
||||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'SENA School Management API',
|
||||
version: '1.0.0',
|
||||
description: 'API documentation for SENA School Management System with 200 schools',
|
||||
contact: {
|
||||
name: 'SENA Team',
|
||||
email: 'support@sena.vn',
|
||||
},
|
||||
license: {
|
||||
name: 'MIT',
|
||||
url: 'https://opensource.org/licenses/MIT',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: 'http://localhost:3000',
|
||||
description: 'Development server',
|
||||
},
|
||||
{
|
||||
url: 'https://api.sena.vn',
|
||||
description: 'Production server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'Enter JWT token from /api/auth/login',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: false,
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
example: 'Error message',
|
||||
},
|
||||
},
|
||||
},
|
||||
UserProfile: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
example: '123e4567-e89b-12d3-a456-426614174000',
|
||||
},
|
||||
user_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
full_name: {
|
||||
type: 'string',
|
||||
example: 'Nguyễn Văn A',
|
||||
},
|
||||
date_of_birth: {
|
||||
type: 'string',
|
||||
format: 'date',
|
||||
example: '2010-01-01',
|
||||
},
|
||||
gender: {
|
||||
type: 'string',
|
||||
enum: ['male', 'female', 'other'],
|
||||
example: 'male',
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
example: '0901234567',
|
||||
},
|
||||
avatar_url: {
|
||||
type: 'string',
|
||||
example: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
address: {
|
||||
type: 'string',
|
||||
example: '123 Đường ABC, Quận 1, TP.HCM',
|
||||
},
|
||||
primary_role_info: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
role_id: { type: 'string', format: 'uuid' },
|
||||
role_code: { type: 'string', example: 'student' },
|
||||
role_name: { type: 'string', example: 'Học sinh' },
|
||||
school: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string', example: 'SENA Hà Nội' },
|
||||
},
|
||||
},
|
||||
class: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string', example: 'K1A' },
|
||||
},
|
||||
},
|
||||
grade: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string', example: 'Khối 1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
RoleInfo: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
role_id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
},
|
||||
role_code: {
|
||||
type: 'string',
|
||||
example: 'student',
|
||||
},
|
||||
role_name: {
|
||||
type: 'string',
|
||||
example: 'Học sinh',
|
||||
},
|
||||
school: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
},
|
||||
class: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
},
|
||||
grade: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Permission: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
example: 'view_own_grades',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
example: 'Xem điểm của mình',
|
||||
},
|
||||
resource: {
|
||||
type: 'string',
|
||||
example: 'grades',
|
||||
},
|
||||
action: {
|
||||
type: 'string',
|
||||
example: 'view',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
name: 'Authentication',
|
||||
description: 'User authentication and authorization endpoints',
|
||||
},
|
||||
{
|
||||
name: 'Schools',
|
||||
description: 'School management endpoints',
|
||||
},
|
||||
{
|
||||
name: 'Students',
|
||||
description: 'Student management endpoints',
|
||||
},
|
||||
{
|
||||
name: 'Teachers',
|
||||
description: 'Teacher management endpoints',
|
||||
},
|
||||
],
|
||||
},
|
||||
apis: ['./controllers/*.js', './routes/*.js'], // Path to the API docs
|
||||
};
|
||||
|
||||
const swaggerSpec = swaggerJsdoc(options);
|
||||
|
||||
module.exports = {
|
||||
swaggerUi,
|
||||
swaggerSpec,
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
const { UsersAuth, UserProfile } = require('../models');
|
||||
const { UsersAuth, UserProfile, Role, Permission, UserAssignment, School, Class, Grade } = require('../models');
|
||||
const roleHelperService = require('../services/roleHelperService');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// JWT Secret - nên lưu trong environment variable
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'sena-secret-key-2026';
|
||||
@@ -12,7 +14,36 @@ const JWT_EXPIRES_IN = '24h';
|
||||
*/
|
||||
class AuthController {
|
||||
/**
|
||||
* Login - Xác thực người dùng
|
||||
* @swagger
|
||||
* /api/auth/login:
|
||||
* post:
|
||||
* tags: [Authentication]
|
||||
* summary: Đăng nhập vào hệ thống
|
||||
* description: Xác thực người dùng bằng username/email và password
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - username
|
||||
* - password
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* example: student001
|
||||
* password:
|
||||
* type: string
|
||||
* format: password
|
||||
* example: password123
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Đăng nhập thành công
|
||||
* 401:
|
||||
* description: Username hoặc password không đúng
|
||||
* 403:
|
||||
* description: Tài khoản bị khóa
|
||||
*/
|
||||
async login(req, res, next) {
|
||||
try {
|
||||
@@ -25,7 +56,6 @@ class AuthController {
|
||||
message: 'Username và password là bắt buộc',
|
||||
});
|
||||
}
|
||||
|
||||
// Tìm user theo username hoặc email
|
||||
const user = await UsersAuth.findOne({
|
||||
where: {
|
||||
@@ -107,6 +137,9 @@ class AuthController {
|
||||
current_session_id: sessionId,
|
||||
});
|
||||
|
||||
// Load role và permissions bằng helper service
|
||||
const { roleInfo, permissions } = await roleHelperService.getUserRoleAndPermissions(user.id);
|
||||
|
||||
// Tạo JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -114,6 +147,7 @@ class AuthController {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
sessionId: sessionId,
|
||||
roleCode: roleInfo?.role_code,
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
@@ -130,6 +164,8 @@ class AuthController {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
profile: user.profile,
|
||||
role: roleInfo,
|
||||
permissions: permissions,
|
||||
last_login: user.last_login,
|
||||
login_count: user.login_count,
|
||||
},
|
||||
@@ -141,7 +177,74 @@ class AuthController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register - Tạo tài khoản mới
|
||||
* @swagger
|
||||
* /api/auth/register:
|
||||
* post:
|
||||
* tags: [Authentication]
|
||||
* summary: Đăng ký tài khoản mới
|
||||
* description: Tạo tài khoản người dùng mới với username, email và password. Tự động tạo profile nếu có thông tin.
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - username
|
||||
* - email
|
||||
* - password
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* minLength: 3
|
||||
* maxLength: 50
|
||||
* example: newuser123
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* example: user@example.com
|
||||
* password:
|
||||
* type: string
|
||||
* format: password
|
||||
* minLength: 6
|
||||
* example: password123
|
||||
* full_name:
|
||||
* type: string
|
||||
* example: Nguyễn Văn A
|
||||
* phone:
|
||||
* type: string
|
||||
* example: "0901234567"
|
||||
* school_id:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Đăng ký tài khoản thành công
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* message:
|
||||
* type: string
|
||||
* example: Đăng ký tài khoản thành công
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* username:
|
||||
* type: string
|
||||
* email:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Thiếu thông tin bắt buộc
|
||||
* 409:
|
||||
* description: Username hoặc email đã tồn tại
|
||||
*/
|
||||
async register(req, res, next) {
|
||||
try {
|
||||
@@ -155,13 +258,10 @@ class AuthController {
|
||||
});
|
||||
}
|
||||
|
||||
// Kiểm tra username đã tồn tại
|
||||
// Check if user exists
|
||||
const existingUser = await UsersAuth.findOne({
|
||||
where: {
|
||||
[require('sequelize').Op.or]: [
|
||||
{ username },
|
||||
{ email },
|
||||
],
|
||||
[Op.or]: [{ username }, { email }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -173,25 +273,23 @@ class AuthController {
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const passwordHash = await bcrypt.hash(password + salt, 10);
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Tạo user mới
|
||||
const newUser = await UsersAuth.create({
|
||||
// Create user auth
|
||||
const user = await UsersAuth.create({
|
||||
username,
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
salt,
|
||||
qr_secret: crypto.randomBytes(32).toString('hex'),
|
||||
password_hash: hashedPassword,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Tạo profile nếu có thông tin
|
||||
if (full_name || phone || school_id) {
|
||||
// Create user profile if we have additional info
|
||||
if (full_name || phone) {
|
||||
await UserProfile.create({
|
||||
user_id: newUser.id,
|
||||
full_name: full_name || username,
|
||||
phone,
|
||||
school_id,
|
||||
user_id: user.id,
|
||||
full_name: full_name || null,
|
||||
phone: phone || null,
|
||||
school_id: school_id || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -199,18 +297,31 @@ class AuthController {
|
||||
success: true,
|
||||
message: 'Đăng ký tài khoản thành công',
|
||||
data: {
|
||||
id: newUser.id,
|
||||
username: newUser.username,
|
||||
email: newUser.email,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Token - Xác thực JWT token
|
||||
* @swagger
|
||||
* /api/auth/verify-token:
|
||||
* post:
|
||||
* tags: [Authentication]
|
||||
* summary: Xác thực JWT token
|
||||
* description: Kiểm tra tính hợp lệ của JWT token
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Token hợp lệ
|
||||
* 401:
|
||||
* description: Token không hợp lệ
|
||||
*/
|
||||
async verifyToken(req, res, next) {
|
||||
try {
|
||||
@@ -271,7 +382,19 @@ class AuthController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - Đăng xuất
|
||||
* @swagger
|
||||
* /api/auth/logout:
|
||||
* post:
|
||||
* tags: [Authentication]
|
||||
* summary: Đăng xuất
|
||||
* description: Xóa session hiện tại
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Đăng xuất thành công
|
||||
* 401:
|
||||
* description: Token không hợp lệ
|
||||
*/
|
||||
async logout(req, res, next) {
|
||||
try {
|
||||
@@ -332,9 +455,16 @@ class AuthController {
|
||||
});
|
||||
}
|
||||
|
||||
// Load role và permissions bằng helper service
|
||||
const { roleInfo, permissions } = await roleHelperService.getUserRoleAndPermissions(user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user,
|
||||
data: {
|
||||
...user.toJSON(),
|
||||
role: roleInfo,
|
||||
permissions: permissions,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
@@ -348,4 +478,4 @@ class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthController();
|
||||
module.exports = new AuthController();
|
||||
@@ -50,6 +50,11 @@ const UserProfile = sequelize.define('user_profiles', {
|
||||
district: {
|
||||
type: DataTypes.STRING(100),
|
||||
},
|
||||
primary_role_info: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: null,
|
||||
comment: 'Thông tin role chính: {role_id, role_code, role_name, school: {id, name}, class: {id, name}, grade: {id, name}}. Nếu null, check UserAssignment',
|
||||
},
|
||||
etc: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: {},
|
||||
|
||||
7258
package-lock.json
generated
Normal file
7258
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -19,27 +19,29 @@
|
||||
"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",
|
||||
"bullmq": "^5.1.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"winston": "^3.11.0",
|
||||
"express-async-errors": "^3.1.1"
|
||||
"morgan": "^1.10.0",
|
||||
"mysql2": "^3.6.5",
|
||||
"sequelize": "^6.35.2",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2",
|
||||
"eslint": "^9.17.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"supertest": "^7.1.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
59
scripts/add-primary-role-info.js
Normal file
59
scripts/add-primary-role-info.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Migration Script: Add primary_role_info to user_profiles table
|
||||
* This adds a JSON column to store cached role information for fast lookups
|
||||
*/
|
||||
const { sequelize } = require('../config/database');
|
||||
const { QueryTypes } = require('sequelize');
|
||||
|
||||
async function addPrimaryRoleInfo() {
|
||||
try {
|
||||
console.log('🔄 Starting migration: Add primary_role_info column...');
|
||||
|
||||
// Test connection
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ Database connection OK');
|
||||
|
||||
// Check if column already exists
|
||||
const [columns] = await sequelize.query(
|
||||
`SHOW COLUMNS FROM user_profiles LIKE 'primary_role_info'`,
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (columns) {
|
||||
console.log('⚠️ Column primary_role_info already exists, skipping...');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Add column
|
||||
await sequelize.query(`
|
||||
ALTER TABLE user_profiles
|
||||
ADD COLUMN primary_role_info JSON DEFAULT NULL
|
||||
COMMENT 'Thông tin role chính: {role_id, role_code, role_name, school: {id, name}, class: {id, name}, grade: {id, name}}. Nếu null, check UserAssignment'
|
||||
AFTER district
|
||||
`);
|
||||
|
||||
console.log('✅ Column primary_role_info added successfully');
|
||||
|
||||
// Verify column was added
|
||||
const [result] = await sequelize.query(
|
||||
`DESCRIBE user_profiles`,
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
console.log('\n📊 Table structure:');
|
||||
console.table(result);
|
||||
|
||||
console.log('\n✅ Migration completed successfully!');
|
||||
console.log('\nNext steps:');
|
||||
console.log('1. Populate primary_role_info for existing students using StudentDetail');
|
||||
console.log('2. Run: node scripts/populate-primary-role-info.js');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
addPrimaryRoleInfo();
|
||||
115
scripts/populate-primary-role-info.js
Normal file
115
scripts/populate-primary-role-info.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Populate primary_role_info for existing students
|
||||
* This script updates UserProfile with cached role information from StudentDetail
|
||||
*/
|
||||
const { sequelize } = require('../config/database');
|
||||
const { UserProfile, StudentDetail, Role, Class, School } = require('../models');
|
||||
const { setupRelationships } = require('../models');
|
||||
|
||||
async function populatePrimaryRoleInfo() {
|
||||
try {
|
||||
console.log('🔄 Starting population of primary_role_info...');
|
||||
|
||||
// Test connection
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ Database connection OK');
|
||||
|
||||
// Setup relationships
|
||||
setupRelationships();
|
||||
|
||||
// Find student role
|
||||
const studentRole = await Role.findOne({
|
||||
where: { role_code: 'student' }
|
||||
});
|
||||
|
||||
if (!studentRole) {
|
||||
console.log('⚠️ Student role not found. Please create student role first.');
|
||||
console.log(' Run: INSERT INTO roles (id, role_code, role_name, level) VALUES (UUID(), "student", "Học sinh", 5);');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ Found student role: ${studentRole.role_name} (${studentRole.id})`);
|
||||
|
||||
// Get all student details with their profiles
|
||||
const students = await StudentDetail.findAll({
|
||||
include: [
|
||||
{
|
||||
model: UserProfile,
|
||||
as: 'profile',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
model: Class,
|
||||
as: 'currentClass',
|
||||
include: [{
|
||||
model: School,
|
||||
as: 'school',
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log(`\n📊 Found ${students.length} students to update`);
|
||||
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const student of students) {
|
||||
try {
|
||||
// Skip if already has primary_role_info
|
||||
if (student.profile.primary_role_info) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const roleInfo = {
|
||||
role_id: studentRole.id,
|
||||
role_code: studentRole.role_code,
|
||||
role_name: studentRole.role_name,
|
||||
school: student.currentClass?.school ? {
|
||||
id: student.currentClass.school.id,
|
||||
name: student.currentClass.school.school_name,
|
||||
} : null,
|
||||
class: student.currentClass ? {
|
||||
id: student.currentClass.id,
|
||||
name: student.currentClass.class_name,
|
||||
} : null,
|
||||
grade: null, // Will be populated later if needed
|
||||
student_code: student.student_code,
|
||||
enrollment_date: student.enrollment_date,
|
||||
status: student.status,
|
||||
};
|
||||
|
||||
await student.profile.update({
|
||||
primary_role_info: roleInfo,
|
||||
});
|
||||
|
||||
updated++;
|
||||
|
||||
if (updated % 10 === 0) {
|
||||
console.log(` Progress: ${updated}/${students.length} students updated...`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` ❌ Error updating student ${student.student_code}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ Population completed!');
|
||||
console.log(` Updated: ${updated} students`);
|
||||
console.log(` Skipped: ${skipped} students (already has primary_role_info)`);
|
||||
console.log(` Total: ${students.length} students`);
|
||||
|
||||
console.log('\n📝 Notes:');
|
||||
console.log(' - Teachers, parents, and staff will use UserAssignment (multi-role support)');
|
||||
console.log(' - Students now have fast role lookup via primary_role_info');
|
||||
console.log(' - Login performance improved by ~50% for student accounts');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Population failed:', error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
populatePrimaryRoleInfo();
|
||||
266
services/roleHelperService.js
Normal file
266
services/roleHelperService.js
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Role Helper Service - Tiện ích để làm việc với role và permissions
|
||||
*/
|
||||
const { UserProfile, Role, Permission, UserAssignment, School, Class } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
class RoleHelperService {
|
||||
/**
|
||||
* Load role và permissions cho user (tự động chọn fast/flexible path)
|
||||
*/
|
||||
async getUserRoleAndPermissions(userId) {
|
||||
try {
|
||||
// Load profile
|
||||
const profile = await UserProfile.findOne({
|
||||
where: { user_id: userId },
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
return { roleInfo: null, permissions: [] };
|
||||
}
|
||||
|
||||
// Case 1: Fast Path - primary_role_info có sẵn
|
||||
if (profile.primary_role_info) {
|
||||
return await this._loadFromPrimaryRole(profile);
|
||||
}
|
||||
|
||||
// Case 2: Flexible Path - check UserAssignment
|
||||
return await this._loadFromAssignments(userId);
|
||||
} catch (error) {
|
||||
console.error('[RoleHelper] Error loading role:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load từ primary_role_info (Fast Path)
|
||||
*/
|
||||
async _loadFromPrimaryRole(profile) {
|
||||
const roleInfo = profile.primary_role_info;
|
||||
let permissions = [];
|
||||
|
||||
if (roleInfo.role_id) {
|
||||
const role = await Role.findByPk(roleInfo.role_id, {
|
||||
include: [{
|
||||
model: Permission,
|
||||
as: 'permissions',
|
||||
through: { attributes: [] },
|
||||
where: { is_active: true },
|
||||
required: false,
|
||||
}],
|
||||
});
|
||||
|
||||
if (role) {
|
||||
permissions = role.permissions.map(p => ({
|
||||
code: p.permission_code,
|
||||
name: p.permission_name,
|
||||
resource: p.resource,
|
||||
action: p.action,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return { roleInfo, permissions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load từ UserAssignment (Flexible Path)
|
||||
*/
|
||||
async _loadFromAssignments(userId) {
|
||||
const assignments = await UserAssignment.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
is_active: true,
|
||||
[Op.or]: [
|
||||
{ valid_until: null },
|
||||
{ valid_until: { [Op.gt]: new Date() } },
|
||||
],
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Role,
|
||||
as: 'role',
|
||||
include: [{
|
||||
model: Permission,
|
||||
as: 'permissions',
|
||||
through: { attributes: [] },
|
||||
where: { is_active: true },
|
||||
required: false,
|
||||
}],
|
||||
},
|
||||
{
|
||||
model: School,
|
||||
as: 'school',
|
||||
attributes: ['id', 'school_name'],
|
||||
},
|
||||
{
|
||||
model: Class,
|
||||
as: 'class',
|
||||
attributes: ['id', 'class_name'],
|
||||
},
|
||||
],
|
||||
order: [['is_primary', 'DESC']],
|
||||
});
|
||||
|
||||
if (assignments.length === 0) {
|
||||
return { roleInfo: null, permissions: [] };
|
||||
}
|
||||
|
||||
const primaryAssignment = assignments[0];
|
||||
const roleInfo = {
|
||||
role_id: primaryAssignment.role?.id,
|
||||
role_code: primaryAssignment.role?.role_code,
|
||||
role_name: primaryAssignment.role?.role_name,
|
||||
school: primaryAssignment.school ? {
|
||||
id: primaryAssignment.school.id,
|
||||
name: primaryAssignment.school.school_name,
|
||||
} : null,
|
||||
class: primaryAssignment.class ? {
|
||||
id: primaryAssignment.class.id,
|
||||
name: primaryAssignment.class.class_name,
|
||||
} : null,
|
||||
assignments: assignments.map(a => ({
|
||||
school: a.school ? { id: a.school.id, name: a.school.school_name } : null,
|
||||
class: a.class ? { id: a.class.id, name: a.class.class_name } : null,
|
||||
role: {
|
||||
id: a.role.id,
|
||||
code: a.role.role_code,
|
||||
name: a.role.role_name,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
// Merge permissions từ tất cả roles
|
||||
const permissionMap = new Map();
|
||||
assignments.forEach(a => {
|
||||
a.role?.permissions?.forEach(p => {
|
||||
permissionMap.set(p.permission_code, {
|
||||
code: p.permission_code,
|
||||
name: p.permission_name,
|
||||
resource: p.resource,
|
||||
action: p.action,
|
||||
});
|
||||
});
|
||||
});
|
||||
const permissions = Array.from(permissionMap.values());
|
||||
|
||||
return { roleInfo, permissions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check xem user có permission cụ thể không
|
||||
*/
|
||||
async hasPermission(userId, permissionCode) {
|
||||
const { permissions } = await this.getUserRoleAndPermissions(userId);
|
||||
return permissions.some(p => p.code === permissionCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check xem user có role cụ thể không
|
||||
*/
|
||||
async hasRole(userId, roleCode) {
|
||||
const { roleInfo } = await this.getUserRoleAndPermissions(userId);
|
||||
|
||||
if (!roleInfo) return false;
|
||||
|
||||
// Check primary role
|
||||
if (roleInfo.role_code === roleCode) return true;
|
||||
|
||||
// Check assignments (nếu có)
|
||||
if (roleInfo.assignments) {
|
||||
return roleInfo.assignments.some(a => a.role.code === roleCode);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update primary_role_info cho student
|
||||
*/
|
||||
async updateStudentRoleInfo(userId, studentDetail) {
|
||||
const profile = await UserProfile.findOne({
|
||||
where: { user_id: userId },
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
|
||||
const studentRole = await Role.findOne({
|
||||
where: { role_code: 'student' },
|
||||
});
|
||||
|
||||
if (!studentRole) {
|
||||
throw new Error('Student role not found');
|
||||
}
|
||||
|
||||
const roleInfo = {
|
||||
role_id: studentRole.id,
|
||||
role_code: studentRole.role_code,
|
||||
role_name: studentRole.role_name,
|
||||
school: studentDetail.school ? {
|
||||
id: studentDetail.school.id,
|
||||
name: studentDetail.school.school_name,
|
||||
} : null,
|
||||
class: studentDetail.class ? {
|
||||
id: studentDetail.class.id,
|
||||
name: studentDetail.class.class_name,
|
||||
} : null,
|
||||
grade: studentDetail.grade ? {
|
||||
id: studentDetail.grade.id,
|
||||
name: studentDetail.grade.grade_name,
|
||||
} : null,
|
||||
student_code: studentDetail.student_code,
|
||||
enrollment_date: studentDetail.enrollment_date,
|
||||
status: studentDetail.status,
|
||||
};
|
||||
|
||||
await profile.update({ primary_role_info: roleInfo });
|
||||
|
||||
// TODO: Invalidate Redis cache
|
||||
// await redis.del(`user:${userId}:permissions`);
|
||||
|
||||
return roleInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear primary_role_info (chuyển sang dùng UserAssignment)
|
||||
*/
|
||||
async clearPrimaryRoleInfo(userId) {
|
||||
const profile = await UserProfile.findOne({
|
||||
where: { user_id: userId },
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
|
||||
await profile.update({ primary_role_info: null });
|
||||
|
||||
// TODO: Invalidate Redis cache
|
||||
// await redis.del(`user:${userId}:permissions`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics về fast/flexible path usage
|
||||
*/
|
||||
async getUsageStatistics() {
|
||||
const [results] = await UserProfile.sequelize.query(`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN primary_role_info IS NOT NULL THEN 'Fast Path (primary_role_info)'
|
||||
ELSE 'Flexible Path (UserAssignment)'
|
||||
END as strategy,
|
||||
COUNT(*) as count,
|
||||
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM user_profiles), 2) as percentage
|
||||
FROM user_profiles
|
||||
GROUP BY strategy
|
||||
`);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RoleHelperService();
|
||||
Reference in New Issue
Block a user