Add LMS progress summary endpoint.

Expose a single learner endpoint that returns the nested LMS hierarchy with the same access-based progress percentages used across the existing content APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-26 04:07:19 -07:00
parent 56cc009579
commit 82de00b1e7
6 changed files with 304 additions and 0 deletions

View File

@ -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": { "/api/v1/logs": {
"get": { "get": {
"description": "Fetches application logs from MongoDB with pagination, level filtering, and search", "description": "Fetches application logs from MongoDB with pagination, level filtering, and search",

View File

@ -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": { "/api/v1/logs": {
"get": { "get": {
"description": "Fetches application logs from MongoDB with pagination, level filtering, and search", "description": "Fetches application logs from MongoDB with pagination, level filtering, and search",

View File

@ -5416,6 +5416,25 @@ paths:
summary: Get my LMS completion history summary: Get my LMS completion history
tags: tags:
- lms - 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: /api/v1/logs:
get: get:
description: Fetches application logs from MongoDB with pagination, level filtering, description: Fetches application logs from MongoDB with pagination, level filtering,

View File

@ -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"`
}

View File

@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
@ -9,6 +10,8 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
const lmsProgressSummaryPageSize int32 = 200
// GetMyLMSProgress godoc // GetMyLMSProgress godoc
// @Summary Get my LMS completion history // @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). // @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 // AdminGetUserLMSLearningActivity godoc
// @Summary Get a user's nested LMS learning activity (admin) // @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). // @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, 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
}
}

View File

@ -85,6 +85,7 @@ func (a *App) initAppRoutes() {
groupV1.Get("/programs", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms) groupV1.Get("/programs", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms)
groupV1.Put("/programs/reorder", a.authMiddleware, a.RequirePermission("programs.reorder"), h.ReorderPrograms) 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", 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.Get("/programs/:id", a.authMiddleware, a.RequirePermission("programs.get"), h.GetProgram)
groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram) groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram)
groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram) groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram)