Compare commits
4 Commits
43f79d34ea
...
7ecfdd9cc8
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ecfdd9cc8 | |||
| 7918e62ca9 | |||
| 4055ad46f6 | |||
| 9c9ab41f41 |
|
|
@ -315,112 +315,11 @@ VALUES
|
|||
ON CONFLICT (set_id, question_id) DO NOTHING;
|
||||
|
||||
-- ======================================================
|
||||
-- Course Management Seed Data
|
||||
-- Course Management seed data removed intentionally.
|
||||
-- Course/category/sub-course/video/practice/question-set fixtures
|
||||
-- are no longer seeded from this baseline script.
|
||||
-- ======================================================
|
||||
|
||||
-- Course Categories
|
||||
INSERT INTO course_categories (name, is_active, created_at) VALUES
|
||||
('Programming', TRUE, CURRENT_TIMESTAMP),
|
||||
('Data Science', TRUE, CURRENT_TIMESTAMP),
|
||||
('Web Development', TRUE, CURRENT_TIMESTAMP);
|
||||
|
||||
-- Courses
|
||||
INSERT INTO courses (category_id, title, description, thumbnail, is_active) VALUES
|
||||
(1, 'Python Programming Fundamentals', 'Learn Python from basics to advanced concepts', 'https://example.com/thumbnails/python.jpg', TRUE),
|
||||
(1, 'JavaScript for Beginners', 'Master JavaScript programming language', 'https://example.com/thumbnails/javascript.jpg', TRUE),
|
||||
(1, 'Advanced Java Development', 'Deep dive into Java enterprise development', 'https://example.com/thumbnails/java.jpg', TRUE),
|
||||
(2, 'Data Analysis with Python', 'Learn data manipulation and analysis using pandas', 'https://example.com/thumbnails/data-analysis.jpg', TRUE),
|
||||
(2, 'Machine Learning Basics', 'Introduction to machine learning algorithms', 'https://example.com/thumbnails/ml.jpg', TRUE),
|
||||
(3, 'Full Stack Web Development', 'Complete guide to modern web development', 'https://example.com/thumbnails/fullstack.jpg', TRUE),
|
||||
(3, 'React.js Masterclass', 'Build dynamic user interfaces with React', 'https://example.com/thumbnails/react.jpg', TRUE);
|
||||
|
||||
-- Sub-courses (replacing Programs/Levels hierarchy)
|
||||
INSERT INTO sub_courses (course_id, title, description, thumbnail, display_order, level, sub_level, is_active) VALUES
|
||||
-- Python Programming Fundamentals sub-courses
|
||||
(1, 'Python Basics - Getting Started', 'Introduction to Python and basic syntax', NULL, 1, 'BEGINNER', 'A1', TRUE),
|
||||
(1, 'Python Basics - Data Types', 'Understanding Python data types and variables', NULL, 2, 'BEGINNER', 'A2', TRUE),
|
||||
(1, 'Python Intermediate - Functions', 'Writing and using functions in Python', NULL, 3, 'INTERMEDIATE', 'B1', TRUE),
|
||||
(1, 'Python Intermediate - Collections', 'Working with Python collections', NULL, 4, 'INTERMEDIATE', 'B2', TRUE),
|
||||
(1, 'Python Advanced - Best Practices', 'Advanced Python concepts and best practices', NULL, 5, 'ADVANCED', 'C1', TRUE),
|
||||
|
||||
-- JavaScript sub-courses
|
||||
(2, 'JavaScript Fundamentals', 'Core JavaScript concepts and syntax', NULL, 1, 'BEGINNER', 'A1', TRUE),
|
||||
(2, 'DOM Manipulation Basics', 'Working with the Document Object Model', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
|
||||
|
||||
-- Java sub-courses
|
||||
(3, 'Java Core Concepts', 'Essential Java programming principles', NULL, 1, 'BEGINNER', 'A1', TRUE),
|
||||
(3, 'Spring Framework Intro', 'Building enterprise applications with Spring', NULL, 2, 'ADVANCED', 'C1', TRUE),
|
||||
|
||||
-- Data Science sub-courses
|
||||
(4, 'Data Analysis Fundamentals', 'Learn data manipulation with pandas', NULL, 1, 'BEGINNER', 'A1', TRUE),
|
||||
(4, 'Advanced Data Analysis', 'Complex data transformations', NULL, 2, 'ADVANCED', 'C1', TRUE),
|
||||
|
||||
-- Machine Learning sub-courses
|
||||
(5, 'ML Basics', 'Introduction to machine learning concepts', NULL, 1, 'BEGINNER', 'A1', TRUE),
|
||||
(5, 'ML Algorithms', 'Understanding common ML algorithms', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
|
||||
|
||||
-- Full Stack Web Development sub-courses
|
||||
(6, 'Frontend Fundamentals', 'HTML, CSS, and JavaScript basics', NULL, 1, 'BEGINNER', 'A1', TRUE),
|
||||
(6, 'Backend Development', 'Server-side programming', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
|
||||
|
||||
-- React.js sub-courses
|
||||
(7, 'React Basics', 'Core React concepts and JSX', NULL, 1, 'BEGINNER', 'A1', TRUE),
|
||||
(7, 'React Advanced Patterns', 'Hooks, context, and performance', NULL, 2, 'ADVANCED', 'C1', TRUE);
|
||||
|
||||
-- Sub-course Videos
|
||||
INSERT INTO sub_course_videos (
|
||||
sub_course_id,
|
||||
title,
|
||||
description,
|
||||
video_url,
|
||||
duration,
|
||||
resolution,
|
||||
visibility,
|
||||
display_order,
|
||||
status
|
||||
) VALUES
|
||||
(1, 'Python Installation Guide', 'Installing Python', 'https://example.com/python-install.mp4', 900, '1080p', 'public', 1, 'PUBLISHED'),
|
||||
(1, 'Your First Python Program', 'Writing and running your first Python script', 'https://example.com/python-hello.mp4', 1200, '1080p', 'public', 2, 'PUBLISHED'),
|
||||
(2, 'Numbers and Math', 'Numeric types in Python', 'https://example.com/python-numbers.mp4', 1500, '720p', 'public', 1, 'PUBLISHED'),
|
||||
(2, 'Strings in Python', 'Working with text data', 'https://example.com/python-strings.mp4', 1300, '1080p', 'public', 2, 'DRAFT'),
|
||||
(3, 'Writing Functions', 'Creating reusable code with functions', 'https://example.com/python-functions.mp4', 1800, '1080p', 'public', 1, 'PUBLISHED');
|
||||
|
||||
-- Practice Question Sets (replacing practices table)
|
||||
INSERT INTO question_sets (id, title, description, set_type, owner_type, owner_id, persona, status) VALUES
|
||||
(2, 'Python Basics Assessment', 'Test Python basics', 'PRACTICE', 'SUB_COURSE', 1, 'beginner', 'PUBLISHED'),
|
||||
(3, 'Data Types Practice', 'Practice Python data types', 'PRACTICE', 'SUB_COURSE', 2, 'beginner', 'PUBLISHED'),
|
||||
(4, 'Functions Quiz', 'Assess function knowledge', 'PRACTICE', 'SUB_COURSE', 3, 'intermediate', 'DRAFT')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Practice Questions (using unified questions table)
|
||||
INSERT INTO questions (id, question_text, question_type, tips, status)
|
||||
VALUES
|
||||
(17, 'What is the correct way to print "Hello World" in Python?', 'MCQ', 'Use print()', 'PUBLISHED'),
|
||||
(18, 'Which is a valid Python variable name?', 'MCQ', 'Variables cannot start with numbers', 'PUBLISHED'),
|
||||
(19, 'How do you convert "123" to an integer?', 'MCQ', 'Use int()', 'PUBLISHED'),
|
||||
(20, 'How many times does range(3) loop run?', 'MCQ', 'Starts from zero', 'PUBLISHED')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Link practice questions to question sets
|
||||
INSERT INTO question_set_items (set_id, question_id, display_order)
|
||||
VALUES
|
||||
(2, 17, 1), (2, 18, 2),
|
||||
(3, 19, 1),
|
||||
(4, 20, 1)
|
||||
ON CONFLICT (set_id, question_id) DO NOTHING;
|
||||
|
||||
-- ======================================================
|
||||
-- User Personas for Practice Sessions
|
||||
-- Link existing users as personas to practice question sets
|
||||
-- ======================================================
|
||||
|
||||
INSERT INTO question_set_personas (question_set_id, user_id, display_order)
|
||||
VALUES
|
||||
(2, 10, 1), (2, 11, 2),
|
||||
(3, 12, 1),
|
||||
(4, 10, 1), (4, 12, 2)
|
||||
ON CONFLICT (question_set_id, user_id) DO NOTHING;
|
||||
|
||||
-- ======================================================
|
||||
-- Team Members / Admin Panel Users (login via /api/v1/team/login)
|
||||
-- Credentials: email + password@123
|
||||
|
|
|
|||
292
docs/HUMAN_LANGUAGE_MOBILE_INTEGRATION_GUIDE.md
Normal file
292
docs/HUMAN_LANGUAGE_MOBILE_INTEGRATION_GUIDE.md
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# Human Language Mobile Integration Guide
|
||||
|
||||
This guide explains how to integrate the new **Human Language** feature in the **Yimaru learner mobile app** (not admin).
|
||||
|
||||
It is designed to keep the existing non-language hierarchy intact while introducing a dedicated CEFR-based flow for language learning.
|
||||
|
||||
---
|
||||
|
||||
## 1) Scope and Goals
|
||||
|
||||
### What is new
|
||||
|
||||
- A dedicated backend API namespace for Human Language:
|
||||
- `GET /api/v1/course-management/human-language/courses/:courseId/lessons?cefr_level=A1..C3`
|
||||
- `POST /api/v1/course-management/human-language/lessons` (admin/instructor side)
|
||||
- `PATCH /api/v1/course-management/human-language/lessons/:id` (admin/instructor side)
|
||||
- CEFR levels are fixed to:
|
||||
- `A1, A2, A3, B1, B2, B3, C1, C2, C3`
|
||||
- No custom sub-levels in Human Language flow.
|
||||
|
||||
### What remains unchanged
|
||||
|
||||
- Existing non-human-language content hierarchy and APIs.
|
||||
- Existing course/category/sub-course endpoints for non-language domains (programming, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 2) Target Hierarchy for Human Language
|
||||
|
||||
- Course Category (Human Language)
|
||||
- Course (e.g., English)
|
||||
- CEFR Lesson Unit (A1..C3 only)
|
||||
- Intro/lesson videos
|
||||
- Practices
|
||||
- Audio questions
|
||||
|
||||
Backend implementation stores CEFR lesson units using the existing sub-course model, with CEFR mapped internally and validated strictly.
|
||||
|
||||
---
|
||||
|
||||
## 3) Authentication and Access
|
||||
|
||||
All Human Language endpoints are under `/api/v1` and require bearer token auth.
|
||||
|
||||
- Header:
|
||||
- `Authorization: Bearer <access_token>`
|
||||
|
||||
### Permission notes
|
||||
|
||||
- **Learner mobile app** typically needs only the `GET` endpoint.
|
||||
- `POST` and `PATCH` are content-management endpoints and should generally be used by admin/instructor clients, not learner clients.
|
||||
|
||||
If learner roles do not currently have `learning_tree.get`, coordinate RBAC assignment for read-only access to the `GET` endpoint.
|
||||
|
||||
---
|
||||
|
||||
## 4) Endpoint Contracts
|
||||
|
||||
## 4.1 Fetch lessons by CEFR level (learner-facing)
|
||||
|
||||
### Request
|
||||
|
||||
`GET /api/v1/course-management/human-language/courses/{courseId}/lessons?cefr_level={A1|A2|A3|B1|B2|B3|C1|C2|C3}`
|
||||
|
||||
### Example
|
||||
|
||||
`GET /api/v1/course-management/human-language/courses/12/lessons?cefr_level=B1`
|
||||
|
||||
### Response (success)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Human-language lessons retrieved successfully",
|
||||
"data": {
|
||||
"course_id": 12,
|
||||
"course_title": "English",
|
||||
"cefr_level": "B1",
|
||||
"lessons": [
|
||||
{
|
||||
"id": 201,
|
||||
"course_id": 12,
|
||||
"title": "B1 Module 1",
|
||||
"description": "Intermediate conversational patterns",
|
||||
"thumbnail": "https://.../thumb.jpg",
|
||||
"display_order": 1,
|
||||
"level": "B1",
|
||||
"video_count": 3,
|
||||
"practice_count": 2,
|
||||
"videos": [
|
||||
{
|
||||
"id": 9001,
|
||||
"title": "B1 Intro",
|
||||
"description": "Lesson intro",
|
||||
"video_url": "https://...",
|
||||
"duration": 420,
|
||||
"resolution": "1080p",
|
||||
"display_order": 1
|
||||
}
|
||||
],
|
||||
"practices": [
|
||||
{
|
||||
"id": 4401,
|
||||
"title": "B1 Speaking Practice",
|
||||
"status": "PUBLISHED",
|
||||
"question_count": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"success": true,
|
||||
"status_code": 200,
|
||||
"metadata": null
|
||||
}
|
||||
```
|
||||
|
||||
### Validation errors
|
||||
|
||||
- Invalid CEFR level:
|
||||
- `400` with error message: `Use one of: A1,A2,A3,B1,B2,B3,C1,C2,C3`
|
||||
- Course not in human language category:
|
||||
- `400` with error message indicating invalid human-language course.
|
||||
|
||||
---
|
||||
|
||||
## 4.2 Create Human Language lesson unit (admin/instructor)
|
||||
|
||||
### Request
|
||||
|
||||
`POST /api/v1/course-management/human-language/lessons`
|
||||
|
||||
```json
|
||||
{
|
||||
"course_id": 12,
|
||||
"title": "A2 Module 1",
|
||||
"description": "A2 speaking fundamentals",
|
||||
"thumbnail": "https://...",
|
||||
"display_order": 1,
|
||||
"cefr_level": "A2"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
Returns created lesson metadata with CEFR-normalized level behavior.
|
||||
|
||||
---
|
||||
|
||||
## 4.3 Update Human Language lesson unit (admin/instructor)
|
||||
|
||||
### Request
|
||||
|
||||
`PATCH /api/v1/course-management/human-language/lessons/{lessonId}`
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "A2 Module 1 - Updated",
|
||||
"description": "Updated description",
|
||||
"cefr_level": "A3",
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Recommended Mobile App Integration Flow
|
||||
|
||||
## Step 1: Discover courses under Human Language category
|
||||
|
||||
Use existing endpoints:
|
||||
|
||||
1. `GET /course-management/categories`
|
||||
2. Pick category where name indicates human language.
|
||||
3. `GET /course-management/categories/:categoryId/courses`
|
||||
|
||||
## Step 2: Select CEFR level in UI
|
||||
|
||||
Use static list in app:
|
||||
|
||||
- `A1, A2, A3, B1, B2, B3, C1, C2, C3`
|
||||
|
||||
Do not allow custom levels in mobile UI.
|
||||
|
||||
## Step 3: Fetch lessons by selected CEFR level
|
||||
|
||||
Call:
|
||||
|
||||
- `GET /course-management/human-language/courses/:courseId/lessons?cefr_level=<selected>`
|
||||
|
||||
Render grouped lessons with nested videos/practices from response.
|
||||
|
||||
## Step 4: Navigate to learning/practice screens
|
||||
|
||||
- Lesson video playback uses returned `videos[]`.
|
||||
- Practice entry uses returned `practices[]` IDs and existing practice-question endpoints.
|
||||
|
||||
---
|
||||
|
||||
## 6) Mobile UI/UX Recommendations
|
||||
|
||||
- Use CEFR tabs/segmented control (`A1`...`C3`) at top.
|
||||
- Cache last selected level per course.
|
||||
- Show empty state per level:
|
||||
- "No lessons available for this level yet."
|
||||
- Sort by `display_order` and maintain backend order.
|
||||
- Show badges:
|
||||
- video count
|
||||
- practice count
|
||||
|
||||
---
|
||||
|
||||
## 7) Error Handling and Resilience
|
||||
|
||||
- `400` invalid level: reset selection to previous valid CEFR value and show toast/snackbar.
|
||||
- `401/403`: trigger token refresh / re-login flow.
|
||||
- `5xx`: show retry UI with exponential backoff.
|
||||
- If category/course fetch succeeds but level fetch fails, keep course visible and allow manual retry.
|
||||
|
||||
---
|
||||
|
||||
## 8) Data Model Mapping (Mobile DTO)
|
||||
|
||||
Suggested DTO for learner app:
|
||||
|
||||
```ts
|
||||
type CefrLevel = "A1"|"A2"|"A3"|"B1"|"B2"|"B3"|"C1"|"C2"|"C3";
|
||||
|
||||
interface HumanLanguageLessonDTO {
|
||||
id: number;
|
||||
courseId: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
order: number;
|
||||
level: CefrLevel;
|
||||
videoCount: number;
|
||||
practiceCount: number;
|
||||
videos: {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
duration: number;
|
||||
order: number;
|
||||
}[];
|
||||
practices: {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
questionCount: number;
|
||||
}[];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9) Backward Compatibility
|
||||
|
||||
- Existing non-language content (programming and other categories) continues to use current APIs and hierarchy unchanged.
|
||||
- New Human Language endpoint is additive and isolated.
|
||||
- Mobile app can progressively enable the new flow for language categories only.
|
||||
|
||||
---
|
||||
|
||||
## 10) QA Checklist for Mobile Team
|
||||
|
||||
- [ ] Category discovery correctly identifies Human Language category.
|
||||
- [ ] CEFR selector only allows A1..C3.
|
||||
- [ ] Fetch by CEFR level returns only matching lessons.
|
||||
- [ ] Video/practice counts match rendered lists.
|
||||
- [ ] Empty-level state works.
|
||||
- [ ] Unauthorized/session-expired flow works.
|
||||
- [ ] Non-language courses still load via existing app flow.
|
||||
|
||||
---
|
||||
|
||||
## 11) Example Call Matrix
|
||||
|
||||
- Load language categories/courses:
|
||||
- `GET /course-management/categories`
|
||||
- `GET /course-management/categories/:id/courses`
|
||||
- Load A1 lessons:
|
||||
- `GET /course-management/human-language/courses/:courseId/lessons?cefr_level=A1`
|
||||
- Load B2 lessons:
|
||||
- `GET /course-management/human-language/courses/:courseId/lessons?cefr_level=B2`
|
||||
|
||||
---
|
||||
|
||||
For backend ownership questions, refer to:
|
||||
|
||||
- `internal/web_server/handlers/course_management.go`
|
||||
- `internal/web_server/routes.go`
|
||||
|
||||
|
|
@ -10,6 +10,8 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
|
@ -18,6 +20,8 @@ import (
|
|||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var humanLanguageModulePattern = regexp.MustCompile(`(?i)^module-(\d+)(?:\.(\d+))?$`)
|
||||
|
||||
// Course Category Handlers
|
||||
|
||||
type createCourseCategoryReq struct {
|
||||
|
|
@ -614,6 +618,472 @@ func isValidSubLevelForLevel(level, subLevel string) bool {
|
|||
}
|
||||
}
|
||||
|
||||
func isValidHumanLanguageCEFRLevel(level string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(level)) {
|
||||
case string(domain.SubCourseSubLevelA1),
|
||||
string(domain.SubCourseSubLevelA2),
|
||||
string(domain.SubCourseSubLevelA3),
|
||||
string(domain.SubCourseSubLevelB1),
|
||||
string(domain.SubCourseSubLevelB2),
|
||||
string(domain.SubCourseSubLevelB3),
|
||||
string(domain.SubCourseSubLevelC1),
|
||||
string(domain.SubCourseSubLevelC2),
|
||||
string(domain.SubCourseSubLevelC3):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func coarseLevelFromCEFR(cefr string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(cefr)) {
|
||||
case string(domain.SubCourseSubLevelA1), string(domain.SubCourseSubLevelA2), string(domain.SubCourseSubLevelA3):
|
||||
return string(domain.SubCourseLevelBeginner)
|
||||
case string(domain.SubCourseSubLevelB1), string(domain.SubCourseSubLevelB2), string(domain.SubCourseSubLevelB3):
|
||||
return string(domain.SubCourseLevelIntermediate)
|
||||
default:
|
||||
return string(domain.SubCourseLevelAdvanced)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ensureCourseIsHumanLanguage(ctx context.Context, courseID int64) (domain.Course, error) {
|
||||
course, err := h.courseMgmtSvc.GetCourseByID(ctx, courseID)
|
||||
if err != nil {
|
||||
return domain.Course{}, err
|
||||
}
|
||||
category, err := h.courseMgmtSvc.GetCourseCategoryByID(ctx, course.CategoryID)
|
||||
if err != nil {
|
||||
return domain.Course{}, err
|
||||
}
|
||||
categoryName := strings.ToLower(strings.TrimSpace(category.Name))
|
||||
if !strings.Contains(categoryName, "language") {
|
||||
return domain.Course{}, fmt.Errorf("course is not under a human language category")
|
||||
}
|
||||
return course, nil
|
||||
}
|
||||
|
||||
type createHumanLanguageLessonReq struct {
|
||||
CourseID int64 `json:"course_id" validate:"required"`
|
||||
Title string `json:"title" validate:"required"`
|
||||
Description *string `json:"description"`
|
||||
Thumbnail *string `json:"thumbnail"`
|
||||
DisplayOrder *int32 `json:"display_order"`
|
||||
CEFRLevel string `json:"cefr_level" validate:"required"`
|
||||
}
|
||||
|
||||
type updateHumanLanguageLessonReq struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Thumbnail *string `json:"thumbnail"`
|
||||
DisplayOrder *int32 `json:"display_order"`
|
||||
CEFRLevel *string `json:"cefr_level"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type humanLanguageLessonRes struct {
|
||||
ID int64 `json:"id"`
|
||||
CourseID int64 `json:"course_id"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||
DisplayOrder int32 `json:"display_order"`
|
||||
Level string `json:"level"`
|
||||
VideoCount int64 `json:"video_count"`
|
||||
PracticeCount int64 `json:"practice_count"`
|
||||
Videos []domain.LearningPathVideo `json:"videos"`
|
||||
Practices []domain.LearningPathPractice `json:"practices"`
|
||||
}
|
||||
|
||||
type getHumanLanguageLessonsRes struct {
|
||||
CourseID int64 `json:"course_id"`
|
||||
CourseTitle string `json:"course_title"`
|
||||
CEFRLevel string `json:"cefr_level"`
|
||||
Lessons []humanLanguageLessonRes `json:"lessons"`
|
||||
}
|
||||
|
||||
type humanLanguageSubModuleRes struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Videos []domain.LearningPathVideo `json:"videos"`
|
||||
Practices []domain.LearningPathPractice `json:"practices"`
|
||||
}
|
||||
|
||||
type humanLanguageModuleRes struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
SubModules []humanLanguageSubModuleRes `json:"sub_modules"`
|
||||
}
|
||||
|
||||
type humanLanguageLevelRes struct {
|
||||
Level string `json:"level"`
|
||||
Modules []humanLanguageModuleRes `json:"modules"`
|
||||
}
|
||||
|
||||
type humanLanguageCourseTreeRes struct {
|
||||
CourseID int64 `json:"course_id"`
|
||||
CourseName string `json:"course_name"`
|
||||
Levels []humanLanguageLevelRes `json:"levels"`
|
||||
}
|
||||
|
||||
type humanLanguageSubCategoryTreeRes struct {
|
||||
SubCategoryID int64 `json:"sub_category_id"`
|
||||
SubCategoryName string `json:"sub_category_name"`
|
||||
Courses []humanLanguageCourseTreeRes `json:"courses"`
|
||||
}
|
||||
|
||||
type humanLanguageHierarchyRes struct {
|
||||
CategoryID int64 `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
SubCategories []humanLanguageSubCategoryTreeRes `json:"sub_categories"`
|
||||
}
|
||||
|
||||
// CreateHumanLanguageLesson godoc
|
||||
// @Summary Create human-language lesson unit
|
||||
// @Description Creates a lesson unit under a human-language course using CEFR level (A1..C3)
|
||||
// @Tags human-language
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body createHumanLanguageLessonReq true "Create human-language lesson payload"
|
||||
// @Success 201 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/course-management/human-language/lessons [post]
|
||||
func (h *Handler) CreateHumanLanguageLesson(c *fiber.Ctx) error {
|
||||
var req createHumanLanguageLessonReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||||
}
|
||||
req.CEFRLevel = strings.ToUpper(strings.TrimSpace(req.CEFRLevel))
|
||||
if !isValidHumanLanguageCEFRLevel(req.CEFRLevel) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid CEFR level",
|
||||
Error: "Use one of: A1,A2,A3,B1,B2,B3,C1,C2,C3",
|
||||
})
|
||||
}
|
||||
if _, err := h.ensureCourseIsHumanLanguage(c.Context(), req.CourseID); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid human-language course", Error: err.Error()})
|
||||
}
|
||||
|
||||
created, err := h.courseMgmtSvc.CreateSubCourse(
|
||||
c.Context(),
|
||||
req.CourseID,
|
||||
req.Title,
|
||||
req.Description,
|
||||
req.Thumbnail,
|
||||
req.DisplayOrder,
|
||||
coarseLevelFromCEFR(req.CEFRLevel),
|
||||
req.CEFRLevel,
|
||||
)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create human-language lesson", Error: err.Error()})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
||||
Message: "Human-language lesson created successfully",
|
||||
Data: subCourseRes{
|
||||
ID: created.ID,
|
||||
CourseID: created.CourseID,
|
||||
Title: created.Title,
|
||||
Description: created.Description,
|
||||
Thumbnail: created.Thumbnail,
|
||||
DisplayOrder: created.DisplayOrder,
|
||||
Level: created.SubLevel,
|
||||
SubLevel: "",
|
||||
IsActive: created.IsActive,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateHumanLanguageLesson godoc
|
||||
// @Summary Update human-language lesson unit
|
||||
// @Description Updates a human-language lesson unit and CEFR level
|
||||
// @Tags human-language
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Lesson (sub-course) ID"
|
||||
// @Param body body updateHumanLanguageLessonReq true "Update human-language lesson payload"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/course-management/human-language/lessons/{id} [patch]
|
||||
func (h *Handler) UpdateHumanLanguageLesson(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid lesson ID", Error: err.Error()})
|
||||
}
|
||||
var req updateHumanLanguageLessonReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||||
}
|
||||
|
||||
var levelPtr *string
|
||||
var subLevelPtr *string
|
||||
if req.CEFRLevel != nil {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(*req.CEFRLevel))
|
||||
if !isValidHumanLanguageCEFRLevel(normalized) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid CEFR level",
|
||||
Error: "Use one of: A1,A2,A3,B1,B2,B3,C1,C2,C3",
|
||||
})
|
||||
}
|
||||
level := coarseLevelFromCEFR(normalized)
|
||||
levelPtr = &level
|
||||
subLevelPtr = &normalized
|
||||
}
|
||||
|
||||
if err := h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, levelPtr, subLevelPtr, req.IsActive); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update human-language lesson", Error: err.Error()})
|
||||
}
|
||||
return c.JSON(domain.Response{Message: "Human-language lesson updated successfully"})
|
||||
}
|
||||
|
||||
// GetHumanLanguageLessonsByCourse godoc
|
||||
// @Summary Get human-language lessons by CEFR level
|
||||
// @Description Returns lessons for a human-language course filtered by CEFR level (A1..C3)
|
||||
// @Tags human-language
|
||||
// @Produce json
|
||||
// @Param courseId path int true "Course ID"
|
||||
// @Param cefr_level query string true "CEFR level (A1..C3)"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/course-management/human-language/courses/{courseId}/lessons [get]
|
||||
func (h *Handler) GetHumanLanguageLessonsByCourse(c *fiber.Ctx) error {
|
||||
courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: err.Error()})
|
||||
}
|
||||
cefrLevel := strings.ToUpper(strings.TrimSpace(c.Query("cefr_level")))
|
||||
if !isValidHumanLanguageCEFRLevel(cefrLevel) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid CEFR level",
|
||||
Error: "Use one of: A1,A2,A3,B1,B2,B3,C1,C2,C3",
|
||||
})
|
||||
}
|
||||
if _, err := h.ensureCourseIsHumanLanguage(c.Context(), courseID); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid human-language course", Error: err.Error()})
|
||||
}
|
||||
|
||||
path, err := h.courseMgmtSvc.GetCourseLearningPath(c.Context(), courseID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to retrieve learning path", Error: err.Error()})
|
||||
}
|
||||
|
||||
lessons := make([]humanLanguageLessonRes, 0)
|
||||
for _, sc := range path.SubCourses {
|
||||
if strings.ToUpper(strings.TrimSpace(sc.SubLevel)) != cefrLevel {
|
||||
continue
|
||||
}
|
||||
lessons = append(lessons, humanLanguageLessonRes{
|
||||
ID: sc.ID,
|
||||
CourseID: courseID,
|
||||
Title: sc.Title,
|
||||
Description: sc.Description,
|
||||
Thumbnail: sc.Thumbnail,
|
||||
DisplayOrder: sc.DisplayOrder,
|
||||
Level: sc.SubLevel,
|
||||
VideoCount: int64(len(sc.Videos)),
|
||||
PracticeCount: int64(len(sc.Practices)),
|
||||
Videos: sc.Videos,
|
||||
Practices: sc.Practices,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Human-language lessons retrieved successfully",
|
||||
Data: getHumanLanguageLessonsRes{
|
||||
CourseID: path.CourseID,
|
||||
CourseTitle: path.CourseTitle,
|
||||
CEFRLevel: cefrLevel,
|
||||
Lessons: lessons,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetHumanLanguageHierarchy godoc
|
||||
// @Summary Get full human-language hierarchy
|
||||
// @Description Returns Category -> SubCategory -> Course -> Level -> Module -> SubModule with videos/practices
|
||||
// @Tags human-language
|
||||
// @Produce json
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/course-management/human-language/hierarchy [get]
|
||||
func (h *Handler) GetHumanLanguageHierarchy(c *fiber.Ctx) error {
|
||||
categories, _, err := h.courseMgmtSvc.GetAllCourseCategories(c.Context(), 1000, 0)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to load course categories",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var humanCategory *domain.CourseCategory
|
||||
for _, cat := range categories {
|
||||
name := strings.ToLower(strings.TrimSpace(cat.Name))
|
||||
if strings.Contains(name, "human language") || strings.Contains(name, "language") {
|
||||
cc := cat
|
||||
humanCategory = &cc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if humanCategory == nil {
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Human-language hierarchy retrieved successfully",
|
||||
Data: humanLanguageHierarchyRes{
|
||||
CategoryID: 0,
|
||||
CategoryName: "Human Language",
|
||||
SubCategories: []humanLanguageSubCategoryTreeRes{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
courses, _, err := h.courseMgmtSvc.GetCoursesByCategory(c.Context(), humanCategory.ID, 1000, 0)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to load human-language subcategories",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
subCategories := make([]humanLanguageSubCategoryTreeRes, 0, len(courses))
|
||||
for _, course := range courses {
|
||||
path, pathErr := h.courseMgmtSvc.GetCourseLearningPath(c.Context(), course.ID)
|
||||
if pathErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
levelsMap := map[string][]humanLanguageModuleRes{}
|
||||
for _, sc := range path.SubCourses {
|
||||
levelKey := strings.ToUpper(strings.TrimSpace(sc.SubLevel))
|
||||
if !isValidHumanLanguageCEFRLevel(levelKey) {
|
||||
continue
|
||||
}
|
||||
levelsMap[levelKey] = append(levelsMap[levelKey], humanLanguageModuleRes{
|
||||
ID: sc.ID,
|
||||
Title: sc.Title,
|
||||
SubModules: []humanLanguageSubModuleRes{
|
||||
{
|
||||
ID: sc.ID,
|
||||
Title: sc.Title,
|
||||
Videos: sc.Videos,
|
||||
Practices: sc.Practices,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
levels := make([]humanLanguageLevelRes, 0, 9)
|
||||
for _, cefr := range []string{
|
||||
string(domain.SubCourseSubLevelA1), string(domain.SubCourseSubLevelA2), string(domain.SubCourseSubLevelA3),
|
||||
string(domain.SubCourseSubLevelB1), string(domain.SubCourseSubLevelB2), string(domain.SubCourseSubLevelB3),
|
||||
string(domain.SubCourseSubLevelC1), string(domain.SubCourseSubLevelC2), string(domain.SubCourseSubLevelC3),
|
||||
} {
|
||||
raw := levelsMap[cefr]
|
||||
moduleBuckets := map[int]*humanLanguageModuleRes{}
|
||||
fallbackCounter := 1000000
|
||||
|
||||
for _, item := range raw {
|
||||
moduleNo := fallbackCounter
|
||||
subNo := 0
|
||||
matched := humanLanguageModulePattern.FindStringSubmatch(strings.TrimSpace(item.Title))
|
||||
if len(matched) > 0 {
|
||||
if parsed, parseErr := strconv.Atoi(matched[1]); parseErr == nil {
|
||||
moduleNo = parsed
|
||||
}
|
||||
if len(matched) > 2 && strings.TrimSpace(matched[2]) != "" {
|
||||
if parsed, parseErr := strconv.Atoi(matched[2]); parseErr == nil {
|
||||
subNo = parsed
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fallbackCounter++
|
||||
}
|
||||
|
||||
mod, exists := moduleBuckets[moduleNo]
|
||||
if !exists {
|
||||
moduleTitle := item.Title
|
||||
if moduleNo < 1000000 {
|
||||
moduleTitle = fmt.Sprintf("Module-%d", moduleNo)
|
||||
}
|
||||
mod = &humanLanguageModuleRes{
|
||||
ID: item.ID,
|
||||
Title: moduleTitle,
|
||||
SubModules: []humanLanguageSubModuleRes{},
|
||||
}
|
||||
moduleBuckets[moduleNo] = mod
|
||||
}
|
||||
|
||||
subModuleTitle := item.Title
|
||||
if moduleNo < 1000000 && subNo > 0 {
|
||||
subModuleTitle = fmt.Sprintf("Sub-Module-%d.%d", moduleNo, subNo)
|
||||
} else if moduleNo < 1000000 && subNo == 0 {
|
||||
subModuleTitle = fmt.Sprintf("Sub-Module-%d.1", moduleNo)
|
||||
}
|
||||
|
||||
sub := humanLanguageSubModuleRes{
|
||||
ID: item.ID,
|
||||
Title: subModuleTitle,
|
||||
Videos: item.SubModules[0].Videos,
|
||||
Practices: item.SubModules[0].Practices,
|
||||
}
|
||||
mod.SubModules = append(mod.SubModules, sub)
|
||||
}
|
||||
|
||||
moduleKeys := make([]int, 0, len(moduleBuckets))
|
||||
for key := range moduleBuckets {
|
||||
moduleKeys = append(moduleKeys, key)
|
||||
}
|
||||
sort.Ints(moduleKeys)
|
||||
|
||||
groupedModules := make([]humanLanguageModuleRes, 0, len(moduleKeys))
|
||||
for _, key := range moduleKeys {
|
||||
mod := moduleBuckets[key]
|
||||
sort.SliceStable(mod.SubModules, func(i, j int) bool {
|
||||
ai := humanLanguageModulePattern.FindStringSubmatch(strings.ReplaceAll(mod.SubModules[i].Title, "Sub-", ""))
|
||||
aj := humanLanguageModulePattern.FindStringSubmatch(strings.ReplaceAll(mod.SubModules[j].Title, "Sub-", ""))
|
||||
ival := 0
|
||||
jval := 0
|
||||
if len(ai) > 2 {
|
||||
ival, _ = strconv.Atoi(ai[2])
|
||||
}
|
||||
if len(aj) > 2 {
|
||||
jval, _ = strconv.Atoi(aj[2])
|
||||
}
|
||||
return ival < jval
|
||||
})
|
||||
groupedModules = append(groupedModules, *mod)
|
||||
}
|
||||
|
||||
levels = append(levels, humanLanguageLevelRes{
|
||||
Level: cefr,
|
||||
Modules: groupedModules,
|
||||
})
|
||||
}
|
||||
|
||||
subCategories = append(subCategories, humanLanguageSubCategoryTreeRes{
|
||||
SubCategoryID: course.ID,
|
||||
SubCategoryName: course.Title,
|
||||
Courses: []humanLanguageCourseTreeRes{
|
||||
{
|
||||
CourseID: course.ID,
|
||||
CourseName: course.Title,
|
||||
Levels: levels,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Human-language hierarchy retrieved successfully",
|
||||
Data: humanLanguageHierarchyRes{
|
||||
CategoryID: humanCategory.ID,
|
||||
CategoryName: humanCategory.Name,
|
||||
SubCategories: subCategories,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// CreateSubCourse godoc
|
||||
// @Summary Create a new sub-course
|
||||
// @Description Creates a new sub-course under a specific course
|
||||
|
|
|
|||
|
|
@ -128,6 +128,10 @@ func (a *App) initAppRoutes() {
|
|||
// Learning Tree
|
||||
groupV1.Get("/course-management/learning-tree", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetFullLearningTree)
|
||||
groupV1.Get("/course-management/courses/:courseId/learning-path", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetCourseLearningPath)
|
||||
groupV1.Get("/course-management/human-language/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetHumanLanguageHierarchy)
|
||||
groupV1.Get("/course-management/human-language/courses/:courseId/lessons", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetHumanLanguageLessonsByCourse)
|
||||
groupV1.Post("/course-management/human-language/lessons", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateHumanLanguageLesson)
|
||||
groupV1.Patch("/course-management/human-language/lessons/:id", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateHumanLanguageLesson)
|
||||
|
||||
// Questions
|
||||
groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion)
|
||||
|
|
|
|||
4
makefile
4
makefile
|
|
@ -66,6 +66,10 @@ seed_data:
|
|||
sleep 1; \
|
||||
done
|
||||
@for file in db/data/*.sql; do \
|
||||
if [ "$$(basename $$file)" = "007_course_management_seed.sql" ]; then \
|
||||
echo "Skipping $$file (course management seed disabled)"; \
|
||||
continue; \
|
||||
fi; \
|
||||
echo "Seeding $$file..."; \
|
||||
cat $$file | docker exec -i yimaru-backend-postgres-1 psql -U root -d gh; \
|
||||
done
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user