This commit is contained in:
634
API_STRUCTURE_GUIDE.md
Normal file
634
API_STRUCTURE_GUIDE.md
Normal file
@@ -0,0 +1,634 @@
|
||||
# API Structure Guide - Curriculum Management
|
||||
|
||||
## 📋 Tổng quan
|
||||
|
||||
API được tổ chức theo cấu trúc phân cấp (hierarchy) và quan hệ nhiều-nhiều:
|
||||
|
||||
### Cấu trúc phân cấp (1:N):
|
||||
```
|
||||
Category (Danh mục)
|
||||
└── Subject (Môn học)
|
||||
└── Chapter (Chương)
|
||||
└── Lesson (Bài học)
|
||||
```
|
||||
|
||||
### Quan hệ nhiều-nhiều (N:N):
|
||||
```
|
||||
Lesson (Bài học) ←→ Story (Câu chuyện)
|
||||
- Một lesson có thể có nhiều stories
|
||||
- Một story có thể được sử dụng trong nhiều lessons
|
||||
- Quản lý qua bảng pivot: lesson_stories
|
||||
```
|
||||
|
||||
## 🎯 Nguyên tắc thiết kế
|
||||
|
||||
### ✅ Điều NÊN làm:
|
||||
1. **Giữ nguyên cấu trúc phân tách**: Mỗi resource (category, subject, chapter, lesson) có router và controller riêng
|
||||
2. **Sử dụng nested routes**: Cho các thao tác liên quan đến parent-child relationship
|
||||
3. **RESTful naming**: Đặt tên routes theo chuẩn REST
|
||||
4. **Validation đầy đủ**: Kiểm tra parent resource tồn tại trước khi thao tác
|
||||
|
||||
### ❌ Điều KHÔNG NÊN làm:
|
||||
1. **Không gộp chung tất cả vào 1 file**: Vi phạm Single Responsibility Principle
|
||||
2. **Không skip validation**: Luôn kiểm tra parent resource
|
||||
3. **Không quên clear cache**: Cache phải được xóa khi có thay đổi
|
||||
|
||||
---
|
||||
|
||||
## 📚 API Endpoints
|
||||
|
||||
### 1. Categories API
|
||||
|
||||
#### CRUD Operations
|
||||
```http
|
||||
GET /api/categories # Lấy danh sách categories
|
||||
POST /api/categories # Tạo category mới
|
||||
GET /api/categories/:id # Lấy chi tiết category
|
||||
PUT /api/categories/:id # Cập nhật category
|
||||
DELETE /api/categories/:id # Xóa category
|
||||
```
|
||||
|
||||
#### Nested Subject Operations
|
||||
```http
|
||||
GET /api/categories/:categoryId/subjects # Lấy subjects trong category
|
||||
POST /api/categories/:categoryId/subjects # Thêm subject vào category
|
||||
DELETE /api/categories/:categoryId/subjects/:subjectId # Xóa subject khỏi category
|
||||
```
|
||||
|
||||
**Ví dụ sử dụng:**
|
||||
```bash
|
||||
# Tạo subject mới trong category
|
||||
curl -X POST http://localhost:3000/api/categories/123e4567-e89b-12d3-a456-426614174000/subjects \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"subject_code": "ENG101",
|
||||
"subject_name": "English Basics",
|
||||
"description": "Basic English course"
|
||||
}'
|
||||
|
||||
# Xóa subject khỏi category
|
||||
curl -X DELETE http://localhost:3000/api/categories/123e4567-e89b-12d3-a456-426614174000/subjects/456e7890-e89b-12d3-a456-426614174001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Subjects API
|
||||
|
||||
#### CRUD Operations
|
||||
```http
|
||||
GET /api/subjects # Lấy danh sách subjects
|
||||
POST /api/subjects # Tạo subject mới
|
||||
GET /api/subjects/:id # Lấy chi tiết subject
|
||||
PUT /api/subjects/:id # Cập nhật subject
|
||||
DELETE /api/subjects/:id # Xóa subject
|
||||
```
|
||||
|
||||
#### Nested Chapter Operations
|
||||
```http
|
||||
GET /api/subjects/:subjectId/chapters # Lấy chapters trong subject
|
||||
POST /api/subjects/:subjectId/chapters # Thêm chapter vào subject
|
||||
DELETE /api/subjects/:subjectId/chapters/:chapterId # Xóa chapter khỏi subject
|
||||
```
|
||||
|
||||
**Ví dụ sử dụng:**
|
||||
```bash
|
||||
# Tạo chapter mới trong subject
|
||||
curl -X POST http://localhost:3000/api/subjects/456e7890-e89b-12d3-a456-426614174001/chapters \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"chapter_number": 1,
|
||||
"chapter_title": "Introduction",
|
||||
"chapter_description": "Getting started with English",
|
||||
"display_order": 1
|
||||
}'
|
||||
|
||||
# Xóa chapter khỏi subject
|
||||
curl -X DELETE http://localhost:3000/api/subjects/456e7890-e89b-12d3-a456-426614174001/chapters/789e0123-e89b-12d3-a456-426614174002
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Chapters API
|
||||
|
||||
#### CRUD Operations
|
||||
```http
|
||||
GET /api/chapters # Lấy danh sách chapters
|
||||
POST /api/chapters # Tạo chapter mới
|
||||
GET /api/chapters/:id # Lấy chi tiết chapter
|
||||
PUT /api/chapters/:id # Cập nhật chapter
|
||||
DELETE /api/chapters/:id # Xóa chapter
|
||||
```
|
||||
|
||||
#### Nested Lesson Operations
|
||||
```http
|
||||
GET /api/chapters/:chapterId/lessons # Lấy lessons trong chapter
|
||||
POST /api/chapters/:chapterId/lessons # Thêm lesson vào chapter
|
||||
DELETE /api/chapters/:chapterId/lessons/:lessonId # Xóa lesson khỏi chapter
|
||||
```
|
||||
|
||||
**Ví dụ sử dụng:**
|
||||
```bash
|
||||
# Tạo lesson mới trong chapter
|
||||
curl -X POST http://localhost:3000/api/chapters/789e0123-e89b-12d3-a456-426614174002/lessons \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"lesson_number": 1,
|
||||
"lesson_title": "Alphabet A-F",
|
||||
"lesson_type": "json_content",
|
||||
"display_order": 1
|
||||
}'
|
||||
|
||||
# Xóa lesson khỏi chapter
|
||||
curl -X DELETE http://localhost:3000/api/chapters/789e0123-e89b-12d3-a456-426614174002/lessons/012e3456-e89b-12d3-a456-426614174003
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Lessons API
|
||||
|
||||
#### CRUD Operations
|
||||
```http
|
||||
GET /api/lessons # Lấy danh sách lessons
|
||||
POST /api/lessons # Tạo lesson mới (cần chỉ định chapter_id)
|
||||
GET /api/lessons/:id # Lấy chi tiết lesson
|
||||
PUT /api/lessons/:id # Cập nhật lesson
|
||||
DELETE /api/lessons/:id # Xóa lesson
|
||||
```
|
||||
|
||||
#### Nested Story Operations (N:N Relationship)
|
||||
```http
|
||||
GET /api/lessons/:lessonId/stories # Lấy stories trong lesson
|
||||
POST /api/lessons/:lessonId/stories # Thêm story vào lesson
|
||||
PUT /api/lessons/:lessonId/stories/:storyId # Cập nhật story trong lesson
|
||||
DELETE /api/lessons/:lessonId/stories/:storyId # Xóa story khỏi lesson
|
||||
```
|
||||
|
||||
**Ví dụ sử dụng:**
|
||||
```bash
|
||||
# Thêm story vào lesson
|
||||
curl -X POST http://localhost:3000/api/lessons/012e3456-e89b-12d3-a456-426614174003/stories \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"story_id": "234e5678-e89b-12d3-a456-426614174004",
|
||||
"display_order": 1,
|
||||
"is_required": true
|
||||
}'
|
||||
|
||||
# Cập nhật story trong lesson
|
||||
curl -X PUT http://localhost:3000/api/lessons/012e3456-e89b-12d3-a456-426614174003/stories/234e5678-e89b-12d3-a456-426614174004 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"display_order": 2,
|
||||
"is_required": false
|
||||
}'
|
||||
|
||||
# Xóa story khỏi lesson
|
||||
curl -X DELETE http://localhost:3000/api/lessons/012e3456-e89b-12d3-a456-426614174003/stories/234e5678-e89b-12d3-a456-426614174004
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Stories API
|
||||
|
||||
#### CRUD Operations
|
||||
```http
|
||||
GET /api/stories # Lấy danh sách stories
|
||||
POST /api/stories # Tạo story mới
|
||||
GET /api/stories/:id # Lấy chi tiết story
|
||||
PUT /api/stories/:id # Cập nhật story
|
||||
DELETE /api/stories/:id # Xóa story
|
||||
```
|
||||
|
||||
#### Nested Lesson Operations (N:N Relationship - Alternative Way)
|
||||
```http
|
||||
GET /api/stories/:storyId/lessons # Lấy lessons sử dụng story này
|
||||
POST /api/stories/:storyId/lessons # Thêm lesson vào story
|
||||
DELETE /api/stories/:storyId/lessons/:lessonId # Xóa lesson khỏi story
|
||||
```
|
||||
|
||||
**Ví dụ sử dụng:**
|
||||
```bash
|
||||
# Lấy danh sách lessons sử dụng story
|
||||
curl -X GET http://localhost:3000/api/stories/234e5678-e89b-12d3-a456-426614174004/lessons
|
||||
|
||||
# Thêm lesson vào story (cách alternative)
|
||||
curl -X POST http://localhost:3000/api/stories/234e5678-e89b-12d3-a456-426614174004/lessons \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"lesson_id": "012e3456-e89b-12d3-a456-426614174003",
|
||||
"display_order": 1,
|
||||
"is_required": true
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 So sánh 2 cách tiếp cận
|
||||
|
||||
### Cách 1: Nested Routes (✅ Được áp dụng)
|
||||
|
||||
#### Quan hệ 1:N (Parent-Child)
|
||||
```http
|
||||
POST /api/categories/:categoryId/subjects
|
||||
POST /api/subjects/:subjectId/chapters
|
||||
POST /api/chapters/:chapterId/lessons
|
||||
```
|
||||
|
||||
#### Quan hệ N:N (Many-to-Many)
|
||||
```http
|
||||
# Từ phía Lesson
|
||||
POST /api/lessons/:lessonId/stories
|
||||
GET /api/lessons/:lessonId/stories
|
||||
PUT /api/lessons/:lessonId/stories/:storyId
|
||||
DELETE /api/lessons/:lessonId/stories/:storyId
|
||||
|
||||
# Từ phía Story (alternative)
|
||||
POST /api/stories/:storyId/lessons
|
||||
GET /api/stories/:storyId/lessons
|
||||
DELETE /api/stories/:storyId/lessons/:lessonId
|
||||
```
|
||||
|
||||
**Ưu điểm:**
|
||||
- ✅ Rõ ràng về mối quan hệ parent-child và many-to-many
|
||||
- ✅ Tự động validate parent tồn tại
|
||||
- ✅ RESTful và semantic
|
||||
- ✅ Dễ hiểu cho developers
|
||||
- ✅ Hỗ trợ quản lý pivot table metadata (display_order, is_required)
|
||||
|
||||
**Nhược điểm:**
|
||||
- ⚠️ URL có thể dài hơn
|
||||
- ⚠️ Cần nhiều routes hơn
|
||||
|
||||
### Cách 2: Flat Routes
|
||||
```http
|
||||
POST /api/subjects (với category_id trong body)
|
||||
POST /api/chapters (với subject_id trong body)
|
||||
POST /api/lessons (với chapter_id trong body)
|
||||
```
|
||||
|
||||
**Ưu điểm:**
|
||||
- ✅ URL ngắn hơn
|
||||
- ✅ Ít routes hơn
|
||||
|
||||
**Nhược điểm:**
|
||||
- ❌ Không rõ ràng về mối quan hệ
|
||||
- ❌ Phải validate parent manually trong body
|
||||
- ❌ Kém RESTful
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Patterns được áp dụng
|
||||
|
||||
### 1. Controller Pattern
|
||||
Mỗi resource có controller riêng:
|
||||
```
|
||||
controllers/
|
||||
├── categoryController.js # Quản lý Categories + nested Subjects
|
||||
├── subjectController.js # Quản lý Subjects + nested Chapters
|
||||
├── chapterController.js # Quản lý Chapters + nested Lessons
|
||||
├── lessonController.js # Quản lý Lessons + nested Stories (N:N)
|
||||
└── storyController.js # Quản lý Stories + nested Lessons (N:N)
|
||||
```
|
||||
|
||||
### 2. Route Organization
|
||||
Mỗi resource có route file riêng:
|
||||
```
|
||||
routes/
|
||||
├── categoryRoutes.js # Category routes + nested Subject routes
|
||||
├── subjectRoutes.js # Subject routes + nested Chapter routes
|
||||
├── chapterRoutes.js # Chapter routes + nested Lesson routes
|
||||
├── lessonRoutes.js # Lesson routes + nested Story routes
|
||||
└── storyRoutes.js # Story routes + nested Lesson routes
|
||||
```
|
||||
|
||||
### 3. Model Relationships
|
||||
```javascript
|
||||
// 1:N Relationships
|
||||
Category.hasMany(Subject)
|
||||
Subject.hasMany(Chapter)
|
||||
Chapter.hasMany(Lesson)
|
||||
|
||||
// N:N Relationship
|
||||
Lesson.belongsToMany(Story, { through: 'LessonStory' })
|
||||
Story.belongsToMany(Lesson, { through: 'LessonStory' })
|
||||
```
|
||||
|
||||
### 4. Validation Pattern
|
||||
```javascript
|
||||
// Luôn validate parent trước khi thao tác child
|
||||
const parent = await ParentModel.findByPk(parentId);
|
||||
if (!parent) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Parent not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate cả 2 phía trong quan hệ N:N
|
||||
const lesson = await Lesson.findByPk(lessonId);
|
||||
const story = await Story.findByPk(storyId);
|
||||
if (!lesson || !story) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Lesson or Story not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Kiểm tra duplicate trong quan hệ N:N
|
||||
const existing = await LessonStory.findOne({
|
||||
where: { lesson_id: lessonId, story_id: storyId }
|
||||
});
|
||||
if (existing) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Relationship already exists'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Cache Invalidation Pattern
|
||||
```javascript
|
||||
// Xóa cache của cả parent và child (1:N)
|
||||
await cacheUtils.deletePattern(`parent:${parentId}:children:*`);
|
||||
await cacheUtils.deletePattern('children:list:*');
|
||||
|
||||
// Xóa cache của cả 2 phía (N:N)
|
||||
await cacheUtils.deletePattern(`lesson:${lessonId}:stories:*`);
|
||||
await cacheUtils.deletePattern(`story:${storyId}:lessons:*`);
|
||||
```
|
||||
|
||||
### 6. Pivot Table Pattern (N:N)
|
||||
```javascript
|
||||
// Bảng LessonStory với metadata
|
||||
{
|
||||
lesson_id: UUID,
|
||||
story_id: UUID,
|
||||
display_order: INTEGER, // Thứ tự hiển thị
|
||||
is_required: BOOLEAN, // Có bắt buộc không
|
||||
}
|
||||
|
||||
// Truy vấn với pivot data
|
||||
const stories = await lesson.getStories({
|
||||
joinTableAttributes: ['display_order', 'is_required']
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Best Practices
|
||||
|
||||
### 1. Đặt tên Routes
|
||||
```javascript
|
||||
// ✅ Đúng: RESTful naming
|
||||
router.post('/:categoryId/subjects', ...)
|
||||
router.delete('/:categoryId/subjects/:subjectId', ...)
|
||||
|
||||
// ❌ Sai: Naming không chuẩn
|
||||
router.post('/:categoryId/addSubject', ...)
|
||||
router.delete('/:categoryId/removeSubject/:subjectId', ...)
|
||||
```
|
||||
|
||||
### 2. Response Structure
|
||||
```javascript
|
||||
// ✅ Đúng: Consistent response structure
|
||||
{
|
||||
"success": true,
|
||||
"message": "Subject added to category successfully",
|
||||
"data": { ...subject }
|
||||
}
|
||||
|
||||
// ❌ Sai: Inconsistent
|
||||
{
|
||||
"subject": { ...subject },
|
||||
"msg": "OK"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Error Handling
|
||||
```javascript
|
||||
// ✅ Đúng: Descriptive error messages
|
||||
if (!chapter) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Chapter not found in this subject'
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ Sai: Generic error
|
||||
if (!chapter) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Request/Response Examples
|
||||
|
||||
### Example 1: Tạo Subject trong Category
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
POST /api/categories/123e4567-e89b-12d3-a456-426614174000/subjects
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"subject_code": "ENG101",
|
||||
"subject_name": "English Basics",
|
||||
"subject_name_en": "English Basics",
|
||||
"description": "Basic English for beginners",
|
||||
"is_active": true,
|
||||
"is_premium": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Subject added to category successfully",
|
||||
"data": {
|
||||
"id": "456e7890-e89b-12d3-a456-426614174001",
|
||||
"category_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"subject_code": "ENG101",
|
||||
"subject_name": "English Basics",
|
||||
"subject_name_en": "English Basics",
|
||||
"description": "Basic English for beginners",
|
||||
"is_active": true,
|
||||
"is_premium": false,
|
||||
"created_at": "2026-02-26T10:00:00.000Z",
|
||||
"updated_at": "2026-02-26T10:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Xóa Chapter khỏi Subject
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
DELETE /api/subjects/456e7890-e89b-12d3-a456-426614174001/chapters/789e0123-e89b-12d3-a456-426614174002
|
||||
```
|
||||
|
||||
**Response (Success):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Chapter removed from subject successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Error - Chapter có lessons):**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Cannot delete chapter. It has 5 lesson(s). Delete lessons first."
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3: Thêm Story vào Lesson (N:N)
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
POST /api/lessons/012e3456-e89b-12d3-a456-426614174003/stories
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"story_id": "234e5678-e89b-12d3-a456-426614174004",
|
||||
"display_order": 1,
|
||||
"is_required": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Story đã được thêm vào bài học",
|
||||
"data": {
|
||||
"id": "345e6789-e89b-12d3-a456-426614174005",
|
||||
"lesson_id": "012e3456-e89b-12d3-a456-426614174003",
|
||||
"story_id": "234e5678-e89b-12d3-a456-426614174004",
|
||||
"display_order": 1,
|
||||
"is_required": true,
|
||||
"created_at": "2026-02-26T11:00:00.000Z",
|
||||
"updated_at": "2026-02-26T11:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 4: Lấy Stories trong Lesson
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
GET /api/lessons/012e3456-e89b-12d3-a456-426614174003/stories?page=1&limit=10
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"lesson": {
|
||||
"id": "012e3456-e89b-12d3-a456-426614174003",
|
||||
"lesson_title": "Alphabet A-F",
|
||||
"lesson_number": 1
|
||||
},
|
||||
"stories": [
|
||||
{
|
||||
"id": "234e5678-e89b-12d3-a456-426614174004",
|
||||
"name": "The Greedy Cat",
|
||||
"type": "story",
|
||||
"thumbnail": "https://cdn.sena.tech/thumbs/greedy-cat.jpg",
|
||||
"display_order": 1,
|
||||
"is_required": true
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"totalPages": 1
|
||||
}
|
||||
},
|
||||
"cached": false
|
||||
}
|
||||
```
|
||||
|
||||
### Example 5: Cập nhật Story trong Lesson
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
PUT /api/lessons/012e3456-e89b-12d3-a456-426614174003/stories/234e5678-e89b-12d3-a456-426614174004
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"display_order": 2,
|
||||
"is_required": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Cập nhật thành công",
|
||||
"data": {
|
||||
"id": "345e6789-e89b-12d3-a456-426614174005",
|
||||
"lesson_id": "012e3456-e89b-12d3-a456-426614174003",
|
||||
"story_id": "234e5678-e89b-12d3-a456-426614174004",
|
||||
"display_order": 2,
|
||||
"is_required": false,
|
||||
"updated_at": "2026-02-26T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Migration Guide
|
||||
|
||||
Nếu bạn đang sử dụng cấu trúc cũ, đây là cách migrate:
|
||||
|
||||
### Before (Cũ)
|
||||
```javascript
|
||||
// Tạo subject
|
||||
POST /api/subjects
|
||||
Body: { category_id: "xxx", subject_code: "ENG101", ... }
|
||||
|
||||
// Tạo chapter
|
||||
POST /api/chapters
|
||||
Body: { subject_id: "yyy", chapter_number: 1, ... }
|
||||
```
|
||||
|
||||
### After (Mới)
|
||||
```javascript
|
||||
// Cách 1: Sử dụng nested routes (khuyến nghị)
|
||||
POST /api/categories/:categoryId/subjects
|
||||
Body: { subject_code: "ENG101", ... } // Không cần category_id
|
||||
|
||||
POST /api/subjects/:subjectId/chapters
|
||||
Body: { chapter_number: 1, ... } // Không cần subject_id
|
||||
|
||||
// Cách 2: Vẫn dùng flat routes (vẫn hỗ trợ)
|
||||
POST /api/subjects
|
||||
Body: { category_id: "xxx", subject_code: "ENG101", ... }
|
||||
|
||||
POST /api/chapters
|
||||
Body: { subject_id: "yyy", chapter_number: 1, ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
1. **Backward Compatibility**: Flat routes vẫn hoạt động bình thường
|
||||
2. **Nested routes**: Được khuyến nghị cho các thao tác mới
|
||||
3. **Cache Strategy**: Automatic cache invalidation được implement ở tất cả endpoints
|
||||
4. **Validation**: Parent resource luôn được validate trước khi thao tác
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Documents
|
||||
|
||||
- [SUBJECT_CHAPTER_LESSON_GUIDE.md](SUBJECT_CHAPTER_LESSON_GUIDE.md)
|
||||
- [LEARNING_CONTENT_GUIDE.md](LEARNING_CONTENT_GUIDE.md)
|
||||
- [SWAGGER_GUIDE.md](SWAGGER_GUIDE.md)
|
||||
307
LESSON_STORY_GUIDE.md
Normal file
307
LESSON_STORY_GUIDE.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# Lesson-Story N:N Relationship Guide
|
||||
|
||||
## 📋 Tổng quan
|
||||
|
||||
Quan hệ **Many-to-Many (N:N)** giữa `Lesson` và `Story`:
|
||||
- Một lesson có thể chứa nhiều stories
|
||||
- Một story có thể được sử dụng trong nhiều lessons
|
||||
- Quản lý qua bảng pivot: `lesson_stories`
|
||||
|
||||
## 🗂️ Database Schema
|
||||
|
||||
### Bảng: lesson_stories
|
||||
```sql
|
||||
CREATE TABLE lesson_stories (
|
||||
id CHAR(36) PRIMARY KEY,
|
||||
lesson_id CHAR(36) NOT NULL,
|
||||
story_id CHAR(36) NOT NULL,
|
||||
display_order INT DEFAULT 0,
|
||||
is_required BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
UNIQUE(lesson_id, story_id)
|
||||
);
|
||||
```
|
||||
|
||||
### Metadata trong Pivot Table
|
||||
- `display_order`: Thứ tự hiển thị story trong lesson
|
||||
- `is_required`: Story có bắt buộc phải hoàn thành không
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### 1. Quản lý từ phía Lesson
|
||||
|
||||
#### Lấy danh sách stories trong lesson
|
||||
```http
|
||||
GET /api/lessons/:lessonId/stories?page=1&limit=20
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"lesson": {
|
||||
"id": "xxx",
|
||||
"lesson_title": "Alphabet A-F",
|
||||
"lesson_number": 1
|
||||
},
|
||||
"stories": [
|
||||
{
|
||||
"id": "yyy",
|
||||
"name": "The Greedy Cat",
|
||||
"type": "story",
|
||||
"display_order": 1,
|
||||
"is_required": true
|
||||
}
|
||||
],
|
||||
"pagination": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Thêm story vào lesson
|
||||
```http
|
||||
POST /api/lessons/:lessonId/stories
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"story_id": "story-uuid",
|
||||
"display_order": 1,
|
||||
"is_required": true
|
||||
}
|
||||
```
|
||||
|
||||
#### Cập nhật story trong lesson
|
||||
```http
|
||||
PUT /api/lessons/:lessonId/stories/:storyId
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"display_order": 2,
|
||||
"is_required": false
|
||||
}
|
||||
```
|
||||
|
||||
#### Xóa story khỏi lesson
|
||||
```http
|
||||
DELETE /api/lessons/:lessonId/stories/:storyId
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Quản lý từ phía Story (Alternative)
|
||||
|
||||
#### Lấy danh sách lessons sử dụng story
|
||||
```http
|
||||
GET /api/stories/:storyId/lessons?page=1&limit=20
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"story": {
|
||||
"id": "yyy",
|
||||
"name": "The Greedy Cat",
|
||||
"type": "story"
|
||||
},
|
||||
"lessons": [
|
||||
{
|
||||
"id": "xxx",
|
||||
"lesson_title": "Alphabet A-F",
|
||||
"display_order": 1,
|
||||
"is_required": true
|
||||
}
|
||||
],
|
||||
"pagination": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Thêm lesson vào story
|
||||
```http
|
||||
POST /api/stories/:storyId/lessons
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"lesson_id": "lesson-uuid",
|
||||
"display_order": 1,
|
||||
"is_required": true
|
||||
}
|
||||
```
|
||||
|
||||
#### Xóa lesson khỏi story
|
||||
```http
|
||||
DELETE /api/stories/:storyId/lessons/:lessonId
|
||||
```
|
||||
|
||||
## 💡 Use Cases
|
||||
|
||||
### Use Case 1: Tạo lesson với nhiều stories
|
||||
```bash
|
||||
# 1. Tạo lesson
|
||||
POST /api/chapters/:chapterId/lessons
|
||||
{
|
||||
"lesson_number": 1,
|
||||
"lesson_title": "Learning ABC",
|
||||
"lesson_type": "json_content"
|
||||
}
|
||||
|
||||
# 2. Thêm story thứ nhất
|
||||
POST /api/lessons/{lesson_id}/stories
|
||||
{
|
||||
"story_id": "story-1-id",
|
||||
"display_order": 1,
|
||||
"is_required": true
|
||||
}
|
||||
|
||||
# 3. Thêm story thứ hai
|
||||
POST /api/lessons/{lesson_id}/stories
|
||||
{
|
||||
"story_id": "story-2-id",
|
||||
"display_order": 2,
|
||||
"is_required": false
|
||||
}
|
||||
```
|
||||
|
||||
### Use Case 2: Tái sử dụng story cho nhiều lessons
|
||||
```bash
|
||||
# Story "The Greedy Cat" được sử dụng trong 3 lessons khác nhau
|
||||
|
||||
# Lesson 1 - Grade 1
|
||||
POST /api/lessons/lesson-1-id/stories
|
||||
{
|
||||
"story_id": "greedy-cat-id",
|
||||
"display_order": 1,
|
||||
"is_required": true
|
||||
}
|
||||
|
||||
# Lesson 2 - Grade 2
|
||||
POST /api/lessons/lesson-2-id/stories
|
||||
{
|
||||
"story_id": "greedy-cat-id",
|
||||
"display_order": 2,
|
||||
"is_required": false
|
||||
}
|
||||
|
||||
# Lesson 3 - Review lesson
|
||||
POST /api/lessons/lesson-3-id/stories
|
||||
{
|
||||
"story_id": "greedy-cat-id",
|
||||
"display_order": 1,
|
||||
"is_required": true
|
||||
}
|
||||
```
|
||||
|
||||
### Use Case 3: Cập nhật thứ tự stories trong lesson
|
||||
```bash
|
||||
# Đổi thứ tự từ 1 sang 3
|
||||
PUT /api/lessons/{lesson_id}/stories/{story_id}
|
||||
{
|
||||
"display_order": 3
|
||||
}
|
||||
|
||||
# Đánh dấu không bắt buộc
|
||||
PUT /api/lessons/{lesson_id}/stories/{story_id}
|
||||
{
|
||||
"is_required": false
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
### 1. Naming Convention
|
||||
- `display_order` bắt đầu từ 1, tăng dần
|
||||
- Để lại khoảng trống giữa các order (1, 5, 10...) để dễ insert sau
|
||||
|
||||
### 2. Validation
|
||||
```javascript
|
||||
// Check duplicate trước khi thêm
|
||||
const existing = await LessonStory.findOne({
|
||||
where: { lesson_id, story_id }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return res.status(400).json({
|
||||
message: 'Story đã tồn tại trong lesson này'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Cache Strategy
|
||||
```javascript
|
||||
// Clear cache của cả 2 phía khi có thay đổi
|
||||
await cacheUtils.deletePattern(`lesson:${lessonId}:stories:*`);
|
||||
await cacheUtils.deletePattern(`story:${storyId}:lessons:*`);
|
||||
```
|
||||
|
||||
### 4. Query Optimization
|
||||
```javascript
|
||||
// Sử dụng include để eager load
|
||||
const lessons = await Lesson.findAll({
|
||||
include: [{
|
||||
model: Story,
|
||||
as: 'stories',
|
||||
through: {
|
||||
attributes: ['display_order', 'is_required']
|
||||
}
|
||||
}]
|
||||
});
|
||||
```
|
||||
|
||||
## ⚠️ Important Notes
|
||||
|
||||
### Cascade Delete
|
||||
- Khi xóa lesson → Tự động xóa relationships trong `lesson_stories`
|
||||
- Khi xóa story → Tự động xóa relationships trong `lesson_stories`
|
||||
- **KHÔNG** xóa story/lesson khi xóa relationship
|
||||
|
||||
### Transaction Safety
|
||||
```javascript
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
// Add multiple stories to lesson
|
||||
await LessonStory.bulkCreate([
|
||||
{ lesson_id, story_id: 'story1', display_order: 1 },
|
||||
{ lesson_id, story_id: 'story2', display_order: 2 },
|
||||
], { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Tips
|
||||
1. **Index**: lesson_id và story_id đều có index
|
||||
2. **Pagination**: Luôn dùng limit khi query
|
||||
3. **Cache**: Cache kết quả 30 phút (1800s)
|
||||
4. **Eager Loading**: Load stories cùng lesson khi cần
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Issue 1: Duplicate Error
|
||||
```
|
||||
Error: Unique constraint violation
|
||||
```
|
||||
**Solution**: Story đã tồn tại trong lesson, kiểm tra trước khi thêm
|
||||
|
||||
### Issue 2: Foreign Key Error
|
||||
```
|
||||
Error: Cannot add or update a child row
|
||||
```
|
||||
**Solution**: lesson_id hoặc story_id không tồn tại, validate trước
|
||||
|
||||
### Issue 3: Order Conflict
|
||||
```
|
||||
Warning: Multiple stories have same display_order
|
||||
```
|
||||
**Solution**: Cập nhật display_order để unique trong lesson
|
||||
|
||||
## 📚 Related Documents
|
||||
- [API_STRUCTURE_GUIDE.md](../API_STRUCTURE_GUIDE.md)
|
||||
- [STORY_GUIDE.md](../STORY_GUIDE.md)
|
||||
- [SUBJECT_CHAPTER_LESSON_GUIDE.md](../SUBJECT_CHAPTER_LESSON_GUIDE.md)
|
||||
@@ -311,7 +311,7 @@ class CategoryController {
|
||||
}
|
||||
|
||||
// Build query conditions
|
||||
const where = { id: id };
|
||||
const where = { category_id: id };
|
||||
if (is_active !== undefined) where.is_active = is_active === 'true';
|
||||
|
||||
// Query subjects
|
||||
@@ -349,6 +349,90 @@ class CategoryController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add subject to category (Create subject within category context)
|
||||
*/
|
||||
async addSubjectToCategory(req, res, next) {
|
||||
try {
|
||||
const { categoryId } = req.params;
|
||||
const subjectData = req.body;
|
||||
|
||||
// Check if category exists
|
||||
const category = await Categories.findByPk(categoryId);
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Category not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Create subject with category_id
|
||||
const subject = await Subject.create({
|
||||
...subjectData,
|
||||
category_id: categoryId,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern('subjects:list:*');
|
||||
await cacheUtils.deletePattern(`category:${categoryId}:subjects:*`);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Subject added to category successfully',
|
||||
data: subject,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove subject from category (Delete subject within category context)
|
||||
*/
|
||||
async removeSubjectFromCategory(req, res, next) {
|
||||
try {
|
||||
const { categoryId, subjectId } = req.params;
|
||||
|
||||
// Check if category exists
|
||||
const category = await Categories.findByPk(categoryId);
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Category not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Find subject
|
||||
const subject = await Subject.findOne({
|
||||
where: {
|
||||
id: subjectId,
|
||||
category_id: categoryId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!subject) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Subject not found in this category',
|
||||
});
|
||||
}
|
||||
|
||||
await subject.destroy();
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.delete(`subject:${subjectId}`);
|
||||
await cacheUtils.deletePattern('subjects:list:*');
|
||||
await cacheUtils.deletePattern(`category:${categoryId}:subjects:*`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Subject removed from category successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CategoryController();
|
||||
|
||||
@@ -330,6 +330,98 @@ class ChapterController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add lesson to chapter (Create lesson within chapter context)
|
||||
*/
|
||||
async addLessonToChapter(req, res, next) {
|
||||
try {
|
||||
const { chapterId } = req.params;
|
||||
const lessonData = req.body;
|
||||
|
||||
// Check if chapter exists
|
||||
const chapter = await Chapter.findByPk(chapterId);
|
||||
if (!chapter) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Chapter not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!lessonData.lesson_number || !lessonData.lesson_title) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'lesson_number and lesson_title are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Create lesson with chapter_id
|
||||
const lesson = await Lesson.create({
|
||||
...lessonData,
|
||||
chapter_id: chapterId,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern('lessons:*');
|
||||
await cacheUtils.deletePattern(`chapter:${chapterId}:lessons:*`);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Lesson added to chapter successfully',
|
||||
data: lesson,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove lesson from chapter (Delete lesson within chapter context)
|
||||
*/
|
||||
async removeLessonFromChapter(req, res, next) {
|
||||
try {
|
||||
const { chapterId, lessonId } = req.params;
|
||||
|
||||
// Check if chapter exists
|
||||
const chapter = await Chapter.findByPk(chapterId);
|
||||
if (!chapter) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Chapter not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Find lesson
|
||||
const lesson = await Lesson.findOne({
|
||||
where: {
|
||||
id: lessonId,
|
||||
chapter_id: chapterId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Lesson not found in this chapter',
|
||||
});
|
||||
}
|
||||
|
||||
await lesson.destroy();
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern('lessons:*');
|
||||
await cacheUtils.deletePattern(`lesson:${lessonId}*`);
|
||||
await cacheUtils.deletePattern(`chapter:${chapterId}:lessons:*`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Lesson removed from chapter successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ChapterController();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const { Lesson, Chapter, Subject, Game } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
const { Story, LessonStory } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
|
||||
/**
|
||||
* Lesson Controller
|
||||
@@ -384,6 +386,243 @@ class LessonController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lấy danh sách stories trong một lesson
|
||||
*/
|
||||
async getStoriesByLesson(req, res, next) {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const cacheKey = `lesson:${lessonId}:stories:${page}:${limit}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if lesson exists
|
||||
const lesson = await Lesson.findByPk(lessonId);
|
||||
if (!lesson) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Không tìm thấy bài học',
|
||||
});
|
||||
}
|
||||
|
||||
// Get stories with pivot data
|
||||
const { count, rows } = await Story.findAndCountAll({
|
||||
include: [
|
||||
{
|
||||
model: Lesson,
|
||||
as: 'lessons',
|
||||
where: { id: lessonId },
|
||||
through: {
|
||||
attributes: ['display_order', 'is_required'],
|
||||
},
|
||||
attributes: [],
|
||||
},
|
||||
],
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [[{ model: Lesson, as: 'lessons' }, LessonStory, 'display_order', 'ASC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
lesson: {
|
||||
id: lesson.id,
|
||||
lesson_title: lesson.lesson_title,
|
||||
lesson_number: lesson.lesson_number,
|
||||
},
|
||||
stories: rows.map(story => ({
|
||||
...story.toJSON(),
|
||||
display_order: story.lessons[0]?.LessonStory?.display_order,
|
||||
is_required: story.lessons[0]?.LessonStory?.is_required,
|
||||
})),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thêm story vào lesson
|
||||
*/
|
||||
async addStoryToLesson(req, res, next) {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const { story_id, display_order = 0, is_required = true } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!story_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'story_id is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if lesson exists
|
||||
const lesson = await Lesson.findByPk(lessonId);
|
||||
if (!lesson) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Không tìm thấy bài học',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if story exists
|
||||
const story = await Story.findByPk(story_id);
|
||||
if (!story) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Không tìm thấy story',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if relationship already exists
|
||||
const existing = await LessonStory.findOne({
|
||||
where: {
|
||||
lesson_id: lessonId,
|
||||
story_id: story_id,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Story đã tồn tại trong bài học này',
|
||||
});
|
||||
}
|
||||
|
||||
// Create relationship
|
||||
const lessonStory = await LessonStory.create({
|
||||
lesson_id: lessonId,
|
||||
story_id: story_id,
|
||||
display_order,
|
||||
is_required,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern(`lesson:${lessonId}:stories:*`);
|
||||
await cacheUtils.deletePattern(`story:${story_id}:lessons:*`);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Story đã được thêm vào bài học',
|
||||
data: lessonStory,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Xóa story khỏi lesson
|
||||
*/
|
||||
async removeStoryFromLesson(req, res, next) {
|
||||
try {
|
||||
const { lessonId, storyId } = req.params;
|
||||
|
||||
// Check if lesson exists
|
||||
const lesson = await Lesson.findByPk(lessonId);
|
||||
if (!lesson) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Không tìm thấy bài học',
|
||||
});
|
||||
}
|
||||
|
||||
// Find relationship
|
||||
const lessonStory = await LessonStory.findOne({
|
||||
where: {
|
||||
lesson_id: lessonId,
|
||||
story_id: storyId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!lessonStory) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Story không tồn tại trong bài học này',
|
||||
});
|
||||
}
|
||||
|
||||
await lessonStory.destroy();
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern(`lesson:${lessonId}:stories:*`);
|
||||
await cacheUtils.deletePattern(`story:${storyId}:lessons:*`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Story đã được xóa khỏi bài học',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cập nhật thông tin story trong lesson (display_order, is_required)
|
||||
*/
|
||||
async updateStoryInLesson(req, res, next) {
|
||||
try {
|
||||
const { lessonId, storyId } = req.params;
|
||||
const { display_order, is_required } = req.body;
|
||||
|
||||
// Find relationship
|
||||
const lessonStory = await LessonStory.findOne({
|
||||
where: {
|
||||
lesson_id: lessonId,
|
||||
story_id: storyId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!lessonStory) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Story không tồn tại trong bài học này',
|
||||
});
|
||||
}
|
||||
|
||||
// Update
|
||||
await lessonStory.update({
|
||||
...(display_order !== undefined && { display_order }),
|
||||
...(is_required !== undefined && { is_required }),
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern(`lesson:${lessonId}:stories:*`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Cập nhật thành công',
|
||||
data: lessonStory,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new LessonController();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const { Story, sequelize } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
const { Lesson, LessonStory } = require('../models');
|
||||
const { cacheUtils } = require('../config/redis');
|
||||
|
||||
/**
|
||||
* CREATE: Add new story
|
||||
@@ -733,3 +735,213 @@ exports.getStoryGuide = async (req, res) => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lấy danh sách lessons sử dụng story này
|
||||
*/
|
||||
exports.getLessonsByStory = async (req, res) => {
|
||||
try {
|
||||
const { storyId } = req.params;
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const cacheKey = `story:${storyId}:lessons:${page}:${limit}`;
|
||||
|
||||
const cached = await cacheUtils.get(cacheKey);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: cached,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if story exists
|
||||
const story = await Story.findByPk(storyId);
|
||||
if (!story) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Story not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Get lessons with pivot data
|
||||
const { count, rows } = await Lesson.findAndCountAll({
|
||||
include: [
|
||||
{
|
||||
model: Story,
|
||||
as: 'stories',
|
||||
where: { id: storyId },
|
||||
through: {
|
||||
attributes: ['display_order', 'is_required'],
|
||||
},
|
||||
attributes: [],
|
||||
},
|
||||
],
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
order: [[{ model: Story, as: 'stories' }, LessonStory, 'display_order', 'ASC']],
|
||||
});
|
||||
|
||||
const result = {
|
||||
story: {
|
||||
id: story.id,
|
||||
name: story.name,
|
||||
type: story.type,
|
||||
},
|
||||
lessons: rows.map(lesson => ({
|
||||
...lesson.toJSON(),
|
||||
display_order: lesson.stories[0]?.LessonStory?.display_order,
|
||||
is_required: lesson.stories[0]?.LessonStory?.is_required,
|
||||
})),
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / parseInt(limit)),
|
||||
},
|
||||
};
|
||||
|
||||
await cacheUtils.set(cacheKey, result, 1800);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching lessons by story:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch lessons',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Thêm lesson vào story (alternative way)
|
||||
*/
|
||||
exports.addLessonToStory = async (req, res) => {
|
||||
try {
|
||||
const { storyId } = req.params;
|
||||
const { lesson_id, display_order = 0, is_required = true } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!lesson_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'lesson_id is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if story exists
|
||||
const story = await Story.findByPk(storyId);
|
||||
if (!story) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Story not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if lesson exists
|
||||
const lesson = await Lesson.findByPk(lesson_id);
|
||||
if (!lesson) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Lesson not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if relationship already exists
|
||||
const existing = await LessonStory.findOne({
|
||||
where: {
|
||||
lesson_id: lesson_id,
|
||||
story_id: storyId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Lesson đã sử dụng story này',
|
||||
});
|
||||
}
|
||||
|
||||
// Create relationship
|
||||
const lessonStory = await LessonStory.create({
|
||||
lesson_id: lesson_id,
|
||||
story_id: storyId,
|
||||
display_order,
|
||||
is_required,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern(`story:${storyId}:lessons:*`);
|
||||
await cacheUtils.deletePattern(`lesson:${lesson_id}:stories:*`);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Lesson đã được thêm vào story',
|
||||
data: lessonStory,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding lesson to story:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to add lesson to story',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Xóa lesson khỏi story
|
||||
*/
|
||||
exports.removeLessonFromStory = async (req, res) => {
|
||||
try {
|
||||
const { storyId, lessonId } = req.params;
|
||||
|
||||
// Check if story exists
|
||||
const story = await Story.findByPk(storyId);
|
||||
if (!story) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Story not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Find relationship
|
||||
const lessonStory = await LessonStory.findOne({
|
||||
where: {
|
||||
lesson_id: lessonId,
|
||||
story_id: storyId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!lessonStory) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Lesson không sử dụng story này',
|
||||
});
|
||||
}
|
||||
|
||||
await lessonStory.destroy();
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern(`story:${storyId}:lessons:*`);
|
||||
await cacheUtils.deletePattern(`lesson:${lessonId}:stories:*`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Lesson đã được xóa khỏi story',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error removing lesson from story:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to remove lesson from story',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -349,6 +349,108 @@ class SubjectController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add chapter to subject (Create chapter within subject context)
|
||||
*/
|
||||
async addChapterToSubject(req, res, next) {
|
||||
try {
|
||||
const { subjectId } = req.params;
|
||||
const chapterData = req.body;
|
||||
|
||||
// Check if subject exists
|
||||
const subject = await Subject.findByPk(subjectId);
|
||||
if (!subject) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Subject not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!chapterData.chapter_number || !chapterData.chapter_title) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'chapter_number and chapter_title are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Create chapter with subject_id
|
||||
const chapter = await Chapter.create({
|
||||
...chapterData,
|
||||
subject_id: subjectId,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await cacheUtils.deletePattern('chapters:*');
|
||||
await cacheUtils.deletePattern(`subject:${subjectId}:chapters:*`);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Chapter added to subject successfully',
|
||||
data: chapter,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove chapter from subject (Delete chapter within subject context)
|
||||
*/
|
||||
async removeChapterFromSubject(req, res, next) {
|
||||
try {
|
||||
const { subjectId, chapterId } = req.params;
|
||||
|
||||
// Check if subject exists
|
||||
const subject = await Subject.findByPk(subjectId);
|
||||
if (!subject) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Subject not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Find chapter
|
||||
const chapter = await Chapter.findOne({
|
||||
where: {
|
||||
id: chapterId,
|
||||
subject_id: subjectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!chapter) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Chapter not found in this subject',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if chapter has lessons
|
||||
const Lesson = require('../models').Lesson;
|
||||
const lessonsCount = await Lesson.count({ where: { chapter_id: chapterId } });
|
||||
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:${chapterId}*`);
|
||||
await cacheUtils.deletePattern(`subject:${subjectId}:chapters:*`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Chapter removed from subject successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SubjectController();
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
@echo off
|
||||
REM Complete bcrypt fix for Windows
|
||||
REM This will remove node_modules and reinstall everything
|
||||
|
||||
echo ================================================================
|
||||
echo Complete Bcrypt Fix Script
|
||||
echo Server: root@senaai.tech
|
||||
echo ================================================================
|
||||
echo.
|
||||
|
||||
ssh root@senaai.tech "cd /var/www/services/sena_db_api && echo 'Removing node_modules...' && rm -rf node_modules package-lock.json pnpm-lock.yaml yarn.lock && echo 'Installing with npm...' && npm install && echo 'Rebuilding bcrypt...' && npm rebuild bcrypt --build-from-source && echo 'Restarting PM2...' && pm2 restart all && pm2 list"
|
||||
|
||||
echo.
|
||||
echo ================================================================
|
||||
echo Done! Check the output above.
|
||||
echo ================================================================
|
||||
pause
|
||||
35
migrations/20260226_create_lesson_stories.sql
Normal file
35
migrations/20260226_create_lesson_stories.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Migration: Create lesson_stories pivot table
|
||||
-- Description: N-N relationship between Lessons and Stories
|
||||
-- Date: 2026-02-26
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lesson_stories` (
|
||||
`id` CHAR(36) NOT NULL PRIMARY KEY,
|
||||
`lesson_id` CHAR(36) NOT NULL,
|
||||
`story_id` CHAR(36) NOT NULL,
|
||||
`display_order` INT DEFAULT 0 COMMENT 'Thứ tự hiển thị story trong lesson',
|
||||
`is_required` BOOLEAN DEFAULT TRUE COMMENT 'Story này có bắt buộc hoàn thành không',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Indexes
|
||||
INDEX `idx_lesson_id` (`lesson_id`),
|
||||
INDEX `idx_story_id` (`story_id`),
|
||||
INDEX `idx_display_order` (`display_order`),
|
||||
|
||||
-- Unique constraint to prevent duplicate relationships
|
||||
UNIQUE KEY `unique_lesson_story` (`lesson_id`, `story_id`),
|
||||
|
||||
-- Foreign keys
|
||||
CONSTRAINT `fk_lesson_stories_lesson`
|
||||
FOREIGN KEY (`lesson_id`)
|
||||
REFERENCES `lessons` (`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
|
||||
CONSTRAINT `fk_lesson_stories_story`
|
||||
FOREIGN KEY (`story_id`)
|
||||
REFERENCES `stories` (`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Pivot table for N-N relationship between Lessons and Stories';
|
||||
56
models/LessonStory.js
Normal file
56
models/LessonStory.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
/**
|
||||
* LessonStory Model - Pivot table for N-N relationship
|
||||
* Many-to-Many: Lessons <-> Stories
|
||||
*/
|
||||
const LessonStory = sequelize.define('lesson_stories', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
lesson_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
comment: 'ID của bài học',
|
||||
},
|
||||
story_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
comment: 'ID của story',
|
||||
},
|
||||
display_order: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: 'Thứ tự hiển thị story trong lesson',
|
||||
},
|
||||
is_required: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
comment: 'Story này có bắt buộc hoàn thành không',
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
}, {
|
||||
tableName: 'lesson_stories',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
indexes: [
|
||||
{ fields: ['lesson_id'] },
|
||||
{ fields: ['story_id'] },
|
||||
{ fields: ['lesson_id', 'story_id'], unique: true },
|
||||
{ fields: ['display_order'] },
|
||||
],
|
||||
});
|
||||
|
||||
module.exports = LessonStory;
|
||||
@@ -33,6 +33,7 @@ const Game = require('./Game');
|
||||
const GameType = require('./GameType');
|
||||
const LessonComponentProgress = require('./LessonComponentProgress');
|
||||
const LessonLeaderboard = require('./LessonLeaderboard');
|
||||
const LessonStory = require('./LessonStory');
|
||||
|
||||
// Group 3.2: Vocabulary System (NEW)
|
||||
const Vocab = require('./Vocab');
|
||||
@@ -162,6 +163,24 @@ const setupRelationships = () => {
|
||||
Chapter.hasMany(Lesson, { foreignKey: 'chapter_id', as: 'lessons' });
|
||||
Lesson.belongsTo(Chapter, { foreignKey: 'chapter_id', as: 'chapter' });
|
||||
|
||||
// Lesson <-> Story (N:N through LessonStory)
|
||||
Lesson.belongsToMany(Story, {
|
||||
through: LessonStory,
|
||||
foreignKey: 'lesson_id',
|
||||
otherKey: 'story_id',
|
||||
as: 'stories'
|
||||
});
|
||||
Story.belongsToMany(Lesson, {
|
||||
through: LessonStory,
|
||||
foreignKey: 'story_id',
|
||||
otherKey: 'lesson_id',
|
||||
as: 'lessons'
|
||||
});
|
||||
|
||||
// Direct access to pivot table
|
||||
LessonStory.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesson' });
|
||||
LessonStory.belongsTo(Story, { foreignKey: 'story_id', as: 'story' });
|
||||
|
||||
// Lesson Progress relationships
|
||||
LessonComponentProgress.belongsTo(UsersAuth, { foreignKey: 'user_id', as: 'user' });
|
||||
LessonComponentProgress.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesson' });
|
||||
@@ -295,6 +314,7 @@ module.exports = {
|
||||
GameType,
|
||||
LessonComponentProgress,
|
||||
LessonLeaderboard,
|
||||
LessonStory,
|
||||
|
||||
// Group 3.2: Vocabulary System (NEW)
|
||||
Vocab,
|
||||
|
||||
@@ -33,4 +33,11 @@ router.put('/:id', categoryController.updateCategory);
|
||||
// DELETE /api/categories/:id - Delete category
|
||||
router.delete('/:id', categoryController.deleteCategory);
|
||||
|
||||
// ============ Nested Subject Routes ============
|
||||
// POST /api/categories/:categoryId/subjects - Add subject to category
|
||||
router.post('/:categoryId/subjects', categoryController.addSubjectToCategory);
|
||||
|
||||
// DELETE /api/categories/:categoryId/subjects/:subjectId - Remove subject from category
|
||||
router.delete('/:categoryId/subjects/:subjectId', categoryController.removeSubjectFromCategory);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -25,4 +25,11 @@ router.put('/:id', chapterController.updateChapter);
|
||||
// Delete chapter
|
||||
router.delete('/:id', chapterController.deleteChapter);
|
||||
|
||||
// ============ Nested Lesson Routes ============
|
||||
// POST /api/chapters/:chapterId/lessons - Add lesson to chapter
|
||||
router.post('/:chapterId/lessons', chapterController.addLessonToChapter);
|
||||
|
||||
// DELETE /api/chapters/:chapterId/lessons/:lessonId - Remove lesson from chapter
|
||||
router.delete('/:chapterId/lessons/:lessonId', chapterController.removeLessonFromChapter);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -247,4 +247,137 @@ router.delete('/:id', lessonController.deleteLesson);
|
||||
*/
|
||||
router.post('/:id/complete', lessonController.completeLesson);
|
||||
|
||||
// ============ Nested Story Routes ============
|
||||
/**
|
||||
* @swagger
|
||||
* /api/lessons/{lessonId}/stories:
|
||||
* get:
|
||||
* tags: [Lessons]
|
||||
* summary: Lấy danh sách stories trong một lesson
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: lessonId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 20
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Danh sách stories trong lesson
|
||||
*/
|
||||
router.get('/:lessonId/stories', lessonController.getStoriesByLesson);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/lessons/{lessonId}/stories:
|
||||
* post:
|
||||
* tags: [Lessons]
|
||||
* summary: Thêm story vào lesson
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: lessonId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - story_id
|
||||
* properties:
|
||||
* story_id:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* display_order:
|
||||
* type: integer
|
||||
* default: 0
|
||||
* is_required:
|
||||
* type: boolean
|
||||
* default: true
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Story đã được thêm vào lesson
|
||||
*/
|
||||
router.post('/:lessonId/stories', lessonController.addStoryToLesson);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/lessons/{lessonId}/stories/{storyId}:
|
||||
* put:
|
||||
* tags: [Lessons]
|
||||
* summary: Cập nhật story trong lesson (display_order, is_required)
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: lessonId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* - in: path
|
||||
* name: storyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* display_order:
|
||||
* type: integer
|
||||
* is_required:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Cập nhật thành công
|
||||
*/
|
||||
router.put('/:lessonId/stories/:storyId', lessonController.updateStoryInLesson);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/lessons/{lessonId}/stories/{storyId}:
|
||||
* delete:
|
||||
* tags: [Lessons]
|
||||
* summary: Xóa story khỏi lesson
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: lessonId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* - in: path
|
||||
* name: storyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Story đã được xóa khỏi lesson
|
||||
*/
|
||||
router.delete('/:lessonId/stories/:storyId', lessonController.removeStoryFromLesson);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -386,4 +386,100 @@ router.put('/:id', storyController.updateStory);
|
||||
*/
|
||||
router.delete('/:id', storyController.deleteStory);
|
||||
|
||||
// ============ Nested Lesson Routes ============
|
||||
/**
|
||||
* @swagger
|
||||
* /api/stories/{storyId}/lessons:
|
||||
* get:
|
||||
* tags: [Stories]
|
||||
* summary: Lấy danh sách lessons sử dụng story này
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: storyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 20
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Danh sách lessons sử dụng story
|
||||
*/
|
||||
router.get('/:storyId/lessons', storyController.getLessonsByStory);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/stories/{storyId}/lessons:
|
||||
* post:
|
||||
* tags: [Stories]
|
||||
* summary: Thêm lesson vào story (alternative way)
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: storyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - lesson_id
|
||||
* properties:
|
||||
* lesson_id:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* display_order:
|
||||
* type: integer
|
||||
* default: 0
|
||||
* is_required:
|
||||
* type: boolean
|
||||
* default: true
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Lesson đã được thêm vào story
|
||||
*/
|
||||
router.post('/:storyId/lessons', storyController.addLessonToStory);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/stories/{storyId}/lessons/{lessonId}:
|
||||
* delete:
|
||||
* tags: [Stories]
|
||||
* summary: Xóa lesson khỏi story
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: storyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* - in: path
|
||||
* name: lessonId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Lesson đã được xóa khỏi story
|
||||
*/
|
||||
router.delete('/:storyId/lessons/:lessonId', storyController.removeLessonFromStory);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -33,4 +33,11 @@ router.put('/:id', subjectController.updateSubject);
|
||||
// DELETE /api/subjects/:id - Delete subject
|
||||
router.delete('/:id', subjectController.deleteSubject);
|
||||
|
||||
// ============ Nested Chapter Routes ============
|
||||
// POST /api/subjects/:subjectId/chapters - Add chapter to subject
|
||||
router.post('/:subjectId/chapters', subjectController.addChapterToSubject);
|
||||
|
||||
// DELETE /api/subjects/:subjectId/chapters/:chapterId - Remove chapter from subject
|
||||
router.delete('/:subjectId/chapters/:chapterId', subjectController.removeChapterFromSubject);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user