diff --git a/API_STRUCTURE_GUIDE.md b/API_STRUCTURE_GUIDE.md new file mode 100644 index 0000000..fc2aa82 --- /dev/null +++ b/API_STRUCTURE_GUIDE.md @@ -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) diff --git a/LESSON_STORY_GUIDE.md b/LESSON_STORY_GUIDE.md new file mode 100644 index 0000000..406bafb --- /dev/null +++ b/LESSON_STORY_GUIDE.md @@ -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) diff --git a/controllers/categoryController.js b/controllers/categoryController.js index 408ae68..ae651ea 100644 --- a/controllers/categoryController.js +++ b/controllers/categoryController.js @@ -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(); diff --git a/controllers/chapterController.js b/controllers/chapterController.js index 4e5a5e4..b8f54c0 100644 --- a/controllers/chapterController.js +++ b/controllers/chapterController.js @@ -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(); diff --git a/controllers/lessonController.js b/controllers/lessonController.js index 444d92f..33d89a2 100644 --- a/controllers/lessonController.js +++ b/controllers/lessonController.js @@ -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(); diff --git a/controllers/storyController.js b/controllers/storyController.js index 1536ec6..848a027 100644 --- a/controllers/storyController.js +++ b/controllers/storyController.js @@ -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, + }); + } +}; diff --git a/controllers/subjectController.js b/controllers/subjectController.js index 2bb0441..01d596f 100644 --- a/controllers/subjectController.js +++ b/controllers/subjectController.js @@ -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(); diff --git a/fix-bcrypt-complete.bat b/fix-bcrypt-complete.bat deleted file mode 100644 index 5a00d2b..0000000 --- a/fix-bcrypt-complete.bat +++ /dev/null @@ -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 diff --git a/migrations/20260226_create_lesson_stories.sql b/migrations/20260226_create_lesson_stories.sql new file mode 100644 index 0000000..99ff684 --- /dev/null +++ b/migrations/20260226_create_lesson_stories.sql @@ -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'; diff --git a/models/LessonStory.js b/models/LessonStory.js new file mode 100644 index 0000000..e2b81f6 --- /dev/null +++ b/models/LessonStory.js @@ -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; diff --git a/models/index.js b/models/index.js index 834a2da..80d336c 100644 --- a/models/index.js +++ b/models/index.js @@ -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, diff --git a/routes/categoryRoutes.js b/routes/categoryRoutes.js index f034220..60df480 100644 --- a/routes/categoryRoutes.js +++ b/routes/categoryRoutes.js @@ -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; diff --git a/routes/chapterRoutes.js b/routes/chapterRoutes.js index 665547b..d3c6c4c 100644 --- a/routes/chapterRoutes.js +++ b/routes/chapterRoutes.js @@ -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; diff --git a/routes/lessonRoutes.js b/routes/lessonRoutes.js index 8c3aac1..36f62d4 100644 --- a/routes/lessonRoutes.js +++ b/routes/lessonRoutes.js @@ -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; diff --git a/routes/storyRoutes.js b/routes/storyRoutes.js index a65c537..640ad3c 100644 --- a/routes/storyRoutes.js +++ b/routes/storyRoutes.js @@ -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; diff --git a/routes/subjectRoutes.js b/routes/subjectRoutes.js index 0f27a23..4ea4ce6 100644 --- a/routes/subjectRoutes.js +++ b/routes/subjectRoutes.js @@ -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;