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
This commit is contained in:
Yared Yemane 2026-04-07 06:13:03 -07:00
parent 9c9ab41f41
commit 4055ad46f6
3 changed files with 439 additions and 0 deletions

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

@ -697,6 +697,42 @@ type getHumanLanguageLessonsRes struct {
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)
@ -860,6 +896,116 @@ func (h *Handler) GetHumanLanguageLessonsByCourse(c *fiber.Ctx) error {
})
}
// 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
}
module := humanLanguageModuleRes{
ID: sc.ID,
Title: sc.Title,
SubModules: []humanLanguageSubModuleRes{
{
ID: sc.ID,
Title: sc.Title,
Videos: sc.Videos,
Practices: sc.Practices,
},
},
}
levelsMap[levelKey] = append(levelsMap[levelKey], module)
}
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),
} {
levels = append(levels, humanLanguageLevelRes{
Level: cefr,
Modules: levelsMap[cefr],
})
}
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,7 @@ 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)