update
All checks were successful
Deploy to Production / deploy (push) Successful in 20s

This commit is contained in:
Ken
2026-02-27 09:38:39 +07:00
parent 9af45a7875
commit 6287a019e3
16 changed files with 2032 additions and 18 deletions

634
API_STRUCTURE_GUIDE.md Normal file
View 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 # Ly danh sách categories
POST /api/categories # To category mi
GET /api/categories/:id # Ly chi tiết category
PUT /api/categories/:id # Cp nht category
DELETE /api/categories/:id # Xóa category
```
#### Nested Subject Operations
```http
GET /api/categories/:categoryId/subjects # Ly subjects trong category
POST /api/categories/:categoryId/subjects # Thêm subject vào category
DELETE /api/categories/:categoryId/subjects/:subjectId # Xóa subject khi 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 # Ly danh sách subjects
POST /api/subjects # To subject mi
GET /api/subjects/:id # Ly chi tiết subject
PUT /api/subjects/:id # Cp nht subject
DELETE /api/subjects/:id # Xóa subject
```
#### Nested Chapter Operations
```http
GET /api/subjects/:subjectId/chapters # Ly chapters trong subject
POST /api/subjects/:subjectId/chapters # Thêm chapter vào subject
DELETE /api/subjects/:subjectId/chapters/:chapterId # Xóa chapter khi 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 # Ly danh sách chapters
POST /api/chapters # To chapter mi
GET /api/chapters/:id # Ly chi tiết chapter
PUT /api/chapters/:id # Cp nht chapter
DELETE /api/chapters/:id # Xóa chapter
```
#### Nested Lesson Operations
```http
GET /api/chapters/:chapterId/lessons # Ly lessons trong chapter
POST /api/chapters/:chapterId/lessons # Thêm lesson vào chapter
DELETE /api/chapters/:chapterId/lessons/:lessonId # Xóa lesson khi 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 # Ly danh sách lessons
POST /api/lessons # To lesson mi (cn ch đnh chapter_id)
GET /api/lessons/:id # Ly chi tiết lesson
PUT /api/lessons/:id # Cp nht lesson
DELETE /api/lessons/:id # Xóa lesson
```
#### Nested Story Operations (N:N Relationship)
```http
GET /api/lessons/:lessonId/stories # Ly stories trong lesson
POST /api/lessons/:lessonId/stories # Thêm story vào lesson
PUT /api/lessons/:lessonId/stories/:storyId # Cp nht story trong lesson
DELETE /api/lessons/:lessonId/stories/:storyId # Xóa story khi 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 # Ly danh sách stories
POST /api/stories # To story mi
GET /api/stories/:id # Ly chi tiết story
PUT /api/stories/:id # Cp nht story
DELETE /api/stories/:id # Xóa story
```
#### Nested Lesson Operations (N:N Relationship - Alternative Way)
```http
GET /api/stories/:storyId/lessons # Ly lessons s dng story này
POST /api/stories/:storyId/lessons # Thêm lesson vào story
DELETE /api/stories/:storyId/lessons/:lessonId # Xóa lesson khi 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 (vi category_id trong body)
POST /api/chapters (vi subject_id trong body)
POST /api/lessons (vi 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
View File

@@ -0,0 +1,307 @@
# Lesson-Story N:N Relationship Guide
## 📋 Tổng quan
Quan hệ **Many-to-Many (N:N)** giữa `Lesson``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)

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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,
});
}
};

View File

@@ -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();

View File

@@ -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

View 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
View 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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;