Compare commits

..

4 Commits

Author SHA1 Message Date
7ecfdd9cc8 removed course management data seed 2026-04-09 01:50:05 -07:00
7918e62ca9 normalize human language module and sub-module grouping
Enhance hierarchy parsing to group Module-N and Module-N.M naming into stable module/sub-module structures under each CEFR level for consistent rendering.

Made-with: Cursor
2026-04-07 06:30:54 -07:00
4055ad46f6 add hierarchy endpoint and mobile integration guide
Expose a dedicated human-language hierarchy endpoint aligned to category/subcategory/course/level/module/sub-module structure and add a complete learner mobile integration guide.

Made-with: Cursor
2026-04-07 06:13:03 -07:00
9c9ab41f41 add dedicated human language backend endpoints
Introduce separate CEFR-based human language lesson APIs for create, update, and level-filtered retrieval while keeping existing non-language course hierarchy endpoints unchanged.

Made-with: Cursor
2026-04-07 05:40:15 -07:00
5 changed files with 773 additions and 104 deletions

View File

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

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

View File

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

View File

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

View File

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