diff --git a/docs/docs.go b/docs/docs.go index 28a7274..3d2c012 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3931,6 +3931,32 @@ const docTemplate = `{ } } }, + "/api/v1/lms/progress-summary": { + "get": { + "description": "Returns the learner's nested LMS hierarchy with the same access progress data exposed on the individual program, course, module, and lesson APIs.", + "produces": [ + "application/json" + ], + "tags": [ + "lms" + ], + "summary": "Get my LMS progress summary", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/logs": { "get": { "description": "Fetches application logs from MongoDB with pagination, level filtering, and search", diff --git a/docs/swagger.json b/docs/swagger.json index c084cbf..4b1ca32 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3923,6 +3923,32 @@ } } }, + "/api/v1/lms/progress-summary": { + "get": { + "description": "Returns the learner's nested LMS hierarchy with the same access progress data exposed on the individual program, course, module, and lesson APIs.", + "produces": [ + "application/json" + ], + "tags": [ + "lms" + ], + "summary": "Get my LMS progress summary", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, "/api/v1/logs": { "get": { "description": "Fetches application logs from MongoDB with pagination, level filtering, and search", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index fafa6a2..947bbf7 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -5416,6 +5416,25 @@ paths: summary: Get my LMS completion history tags: - lms + /api/v1/lms/progress-summary: + get: + description: Returns the learner's nested LMS hierarchy with the same access + progress data exposed on the individual program, course, module, and lesson + APIs. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Get my LMS progress summary + tags: + - lms /api/v1/logs: get: description: Fetches application logs from MongoDB with pagination, level filtering, diff --git a/internal/domain/lms_progress_summary.go b/internal/domain/lms_progress_summary.go new file mode 100644 index 0000000..d712c35 --- /dev/null +++ b/internal/domain/lms_progress_summary.go @@ -0,0 +1,38 @@ +package domain + +// LMSProgressSummary returns the learner's progress tree using the same access +// contract exposed by the LMS hierarchy endpoints. +type LMSProgressSummary struct { + Programs []LMSProgressSummaryProgram `json:"programs"` +} + +type LMSProgressSummaryProgram struct { + ID int64 `json:"id"` + Name string `json:"name"` + Access *LMSEntityAccess `json:"access,omitempty"` + Courses []LMSProgressSummaryCourse `json:"courses"` +} + +type LMSProgressSummaryCourse struct { + ID int64 `json:"id"` + ProgramID int64 `json:"program_id"` + Name string `json:"name"` + Access *LMSEntityAccess `json:"access,omitempty"` + Modules []LMSProgressSummaryModule `json:"modules"` +} + +type LMSProgressSummaryModule struct { + ID int64 `json:"id"` + ProgramID int64 `json:"program_id"` + CourseID int64 `json:"course_id"` + Name string `json:"name"` + Access *LMSEntityAccess `json:"access,omitempty"` + Lessons []LMSProgressSummaryLesson `json:"lessons"` +} + +type LMSProgressSummaryLesson struct { + ID int64 `json:"id"` + ModuleID int64 `json:"module_id"` + Title string `json:"title"` + Access *LMSEntityAccess `json:"access,omitempty"` +} diff --git a/internal/web_server/handlers/lms_progress_handler.go b/internal/web_server/handlers/lms_progress_handler.go index e0522a0..904dd62 100644 --- a/internal/web_server/handlers/lms_progress_handler.go +++ b/internal/web_server/handlers/lms_progress_handler.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "errors" "strconv" @@ -9,6 +10,8 @@ import ( "github.com/gofiber/fiber/v2" ) +const lmsProgressSummaryPageSize int32 = 200 + // GetMyLMSProgress godoc // @Summary Get my LMS completion history // @Description Returns practice-based completed lesson, module, course, and program IDs for the authenticated user (ordered by completion time, then id). @@ -34,6 +37,32 @@ func (h *Handler) GetMyLMSProgress(c *fiber.Ctx) error { }) } +// GetMyLMSProgressSummary godoc +// @Summary Get my LMS progress summary +// @Description Returns the learner's nested LMS hierarchy with the same access progress data exposed on the individual program, course, module, and lesson APIs. +// @Tags lms +// @Produce json +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/lms/progress-summary [get] +func (h *Handler) GetMyLMSProgressSummary(c *fiber.Ctx) error { + uid := c.Locals("user_id").(int64) + role := c.Locals("role").(domain.Role) + summary, err := h.buildLMSProgressSummary(c.Context(), role, uid, !h.canManageLessons(c)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load learning progress summary", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "LMS progress summary retrieved successfully", + Data: summary, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + // AdminGetUserLMSLearningActivity godoc // @Summary Get a user's nested LMS learning activity (admin) // @Description Returns programs, courses, modules, and lessons with completion details and completed practices. Only persisted completion signals are included (completed lessons, completed published practices, and rollup completion timestamps—not partial or in-progress attempts). @@ -140,3 +169,168 @@ func (h *Handler) AdminGetUserRecentActivity(c *fiber.Ctx) error { StatusCode: fiber.StatusOK, }) } + +func (h *Handler) buildLMSProgressSummary(ctx context.Context, role domain.Role, userID int64, publishedOnly bool) (domain.LMSProgressSummary, error) { + programs, err := h.listAllPrograms(ctx) + if err != nil { + return domain.LMSProgressSummary{}, err + } + summary := domain.LMSProgressSummary{ + Programs: make([]domain.LMSProgressSummaryProgram, 0, len(programs)), + } + + for i := range programs { + if err := h.lmsProgressSvc.ApplyAccessProgram(ctx, role, userID, &programs[i]); err != nil { + return domain.LMSProgressSummary{}, err + } + courses, err := h.listAllCoursesByProgram(ctx, programs[i].ID) + if err != nil { + return domain.LMSProgressSummary{}, err + } + programSummary := domain.LMSProgressSummaryProgram{ + ID: programs[i].ID, + Name: programs[i].Name, + Access: programs[i].Access, + Courses: make([]domain.LMSProgressSummaryCourse, 0, len(courses)), + } + + for j := range courses { + if err := h.lmsProgressSvc.ApplyAccessCourse(ctx, role, userID, &courses[j]); err != nil { + return domain.LMSProgressSummary{}, err + } + modules, err := h.listAllModulesByCourse(ctx, courses[j].ID) + if err != nil { + return domain.LMSProgressSummary{}, err + } + courseSummary := domain.LMSProgressSummaryCourse{ + ID: courses[j].ID, + ProgramID: courses[j].ProgramID, + Name: courses[j].Name, + Access: courses[j].Access, + Modules: make([]domain.LMSProgressSummaryModule, 0, len(modules)), + } + + for k := range modules { + if err := h.lmsProgressSvc.ApplyAccessModule(ctx, role, userID, &modules[k]); err != nil { + return domain.LMSProgressSummary{}, err + } + lessons, err := h.listAllLessonsByModule(ctx, modules[k].ID, publishedOnly) + if err != nil { + return domain.LMSProgressSummary{}, err + } + moduleSummary := domain.LMSProgressSummaryModule{ + ID: modules[k].ID, + ProgramID: modules[k].ProgramID, + CourseID: modules[k].CourseID, + Name: modules[k].Name, + Access: modules[k].Access, + Lessons: make([]domain.LMSProgressSummaryLesson, 0, len(lessons)), + } + + for m := range lessons { + if err := h.lmsProgressSvc.ApplyAccessLesson(ctx, role, userID, &lessons[m]); err != nil { + return domain.LMSProgressSummary{}, err + } + moduleSummary.Lessons = append(moduleSummary.Lessons, domain.LMSProgressSummaryLesson{ + ID: lessons[m].ID, + ModuleID: lessons[m].ModuleID, + Title: lessons[m].Title, + Access: lessons[m].Access, + }) + } + + courseSummary.Modules = append(courseSummary.Modules, moduleSummary) + } + + programSummary.Courses = append(programSummary.Courses, courseSummary) + } + + summary.Programs = append(summary.Programs, programSummary) + } + + return summary, nil +} + +func (h *Handler) listAllPrograms(ctx context.Context) ([]domain.Program, error) { + var ( + all []domain.Program + offset int32 + ) + for { + items, total, err := h.programSvc.List(ctx, lmsProgressSummaryPageSize, offset) + if err != nil { + return nil, err + } + all = append(all, items...) + if len(items) == 0 || int64(len(all)) >= total { + if all == nil { + return []domain.Program{}, nil + } + return all, nil + } + offset += lmsProgressSummaryPageSize + } +} + +func (h *Handler) listAllCoursesByProgram(ctx context.Context, programID int64) ([]domain.Course, error) { + var ( + all []domain.Course + offset int32 + ) + for { + items, total, err := h.courseSvc.ListByProgram(ctx, programID, lmsProgressSummaryPageSize, offset) + if err != nil { + return nil, err + } + all = append(all, items...) + if len(items) == 0 || int64(len(all)) >= total { + if all == nil { + return []domain.Course{}, nil + } + return all, nil + } + offset += lmsProgressSummaryPageSize + } +} + +func (h *Handler) listAllModulesByCourse(ctx context.Context, courseID int64) ([]domain.Module, error) { + var ( + all []domain.Module + offset int32 + ) + for { + items, total, err := h.moduleSvc.ListByCourse(ctx, courseID, lmsProgressSummaryPageSize, offset) + if err != nil { + return nil, err + } + all = append(all, items...) + if len(items) == 0 || int64(len(all)) >= total { + if all == nil { + return []domain.Module{}, nil + } + return all, nil + } + offset += lmsProgressSummaryPageSize + } +} + +func (h *Handler) listAllLessonsByModule(ctx context.Context, moduleID int64, publishedOnly bool) ([]domain.Lesson, error) { + var ( + all []domain.Lesson + offset int32 + ) + for { + items, total, err := h.lessonSvc.ListByModule(ctx, moduleID, publishedOnly, lmsProgressSummaryPageSize, offset) + if err != nil { + return nil, err + } + all = append(all, items...) + if len(items) == 0 || int64(len(all)) >= total { + if all == nil { + return []domain.Lesson{}, nil + } + return all, nil + } + offset += lmsProgressSummaryPageSize + } +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 88463f9..1d3133a 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -85,6 +85,7 @@ func (a *App) initAppRoutes() { groupV1.Get("/programs", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms) groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms) groupV1.Get("/lms/progress", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgress) + groupV1.Get("/lms/progress-summary", a.authMiddleware, a.RequireActiveSubscription(), a.RequirePermission("lms.get_my_progress"), h.GetMyLMSProgressSummary) groupV1.Get("/programs/:id", a.authMiddleware, a.RequirePermission("programs.get"), h.GetProgram) groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram) groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram)