From d55873909714da4cf3d569b92b6e482dc629a307 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 10 Mar 2026 02:35:13 -0700 Subject: [PATCH] preogress tracker fix --- db/data/007_course_management_seed.sql | 169 +++++++++++++++--- ...RNER_PROGRESS_TRACKER_ADMIN_INTEGRATION.md | 51 ++++++ internal/ports/course_management.go | 1 + internal/repository/progression.go | 84 +++++++++ .../services/course_management/progression.go | 4 + .../web_server/handlers/course_management.go | 6 + .../handlers/progression_handler.go | 92 ++++++++++ internal/web_server/handlers/questions.go | 12 ++ internal/web_server/routes.go | 1 + 9 files changed, 393 insertions(+), 27 deletions(-) diff --git a/db/data/007_course_management_seed.sql b/db/data/007_course_management_seed.sql index 46ec8df..2b989bb 100644 --- a/db/data/007_course_management_seed.sql +++ b/db/data/007_course_management_seed.sql @@ -305,38 +305,151 @@ INSERT INTO sub_course_prerequisites (sub_course_id, prerequisite_sub_course_id) ON CONFLICT (sub_course_id, prerequisite_sub_course_id) DO NOTHING; -- ====================================================== --- User Sub-course Progress --- Simulate realistic student progress for admin panel +-- Completion-driven progress seed (auto-aggregate model) +-- Seed video/practice completion records, then derive sub-course progress -- ====================================================== -INSERT INTO user_sub_course_progress (user_id, sub_course_id, status, progress_percentage, started_at, completed_at) VALUES --- Student 10 (Demo Student): working through Python course -(10, 1, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '30 days', CURRENT_TIMESTAMP - INTERVAL '20 days'), -(10, 2, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '20 days', CURRENT_TIMESTAMP - INTERVAL '12 days'), -(10, 3, 'IN_PROGRESS', 65, CURRENT_TIMESTAMP - INTERVAL '12 days', NULL), --- Student 10: started Flutter -(10, 18, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '15 days', CURRENT_TIMESTAMP - INTERVAL '8 days'), -(10, 19, 'IN_PROGRESS', 40, CURRENT_TIMESTAMP - INTERVAL '8 days', NULL), +-- Video completions +INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at) +SELECT 10, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '20 days', CURRENT_TIMESTAMP - INTERVAL '20 days' +FROM sub_course_videos v +WHERE v.sub_course_id IN (1, 2, 18) + AND v.status = 'PUBLISHED' +ON CONFLICT (user_id, video_id) DO NOTHING; --- Student 11 (Abebe): completed Python, started JavaScript -(11, 1, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '45 days', CURRENT_TIMESTAMP - INTERVAL '35 days'), -(11, 2, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '35 days', CURRENT_TIMESTAMP - INTERVAL '25 days'), -(11, 3, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '25 days', CURRENT_TIMESTAMP - INTERVAL '15 days'), -(11, 4, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '15 days', CURRENT_TIMESTAMP - INTERVAL '5 days'), -(11, 5, 'IN_PROGRESS', 30, CURRENT_TIMESTAMP - INTERVAL '5 days', NULL), -(11, 6, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '20 days', CURRENT_TIMESTAMP - INTERVAL '10 days'), -(11, 7, 'IN_PROGRESS', 50, CURRENT_TIMESTAMP - INTERVAL '10 days', NULL), +INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at) +SELECT 10, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '8 days', CURRENT_TIMESTAMP - INTERVAL '8 days' +FROM sub_course_videos v +WHERE v.sub_course_id = 19 + AND v.status = 'PUBLISHED' + AND v.display_order = 1 +ON CONFLICT (user_id, video_id) DO NOTHING; --- Student 11: Docker course -(11, 25, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '10 days', CURRENT_TIMESTAMP - INTERVAL '3 days'), -(11, 26, 'IN_PROGRESS', 20, CURRENT_TIMESTAMP - INTERVAL '3 days', NULL), +INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at) +SELECT 11, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '25 days', CURRENT_TIMESTAMP - INTERVAL '25 days' +FROM sub_course_videos v +WHERE v.sub_course_id IN (1, 2, 25) + AND v.status = 'PUBLISHED' +ON CONFLICT (user_id, video_id) DO NOTHING; --- Student 12 (Sara): just started -(12, 1, 'IN_PROGRESS', 25, CURRENT_TIMESTAMP - INTERVAL '7 days', NULL), -(12, 18, 'IN_PROGRESS', 10, CURRENT_TIMESTAMP - INTERVAL '3 days', NULL), -(12, 22, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '14 days', CURRENT_TIMESTAMP - INTERVAL '7 days'), -(12, 23, 'IN_PROGRESS', 60, CURRENT_TIMESTAMP - INTERVAL '7 days', NULL) -ON CONFLICT (user_id, sub_course_id) DO NOTHING; +INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at) +SELECT 11, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '3 days', CURRENT_TIMESTAMP - INTERVAL '3 days' +FROM sub_course_videos v +WHERE v.sub_course_id = 26 + AND v.status = 'PUBLISHED' +ON CONFLICT (user_id, video_id) DO NOTHING; + +INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at) +SELECT 12, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '7 days', CURRENT_TIMESTAMP - INTERVAL '7 days' +FROM sub_course_videos v +WHERE v.sub_course_id = 22 + AND v.status = 'PUBLISHED' +ON CONFLICT (user_id, video_id) DO NOTHING; + +INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at) +SELECT 12, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '3 days', CURRENT_TIMESTAMP - INTERVAL '3 days' +FROM sub_course_videos v +WHERE v.sub_course_id = 18 + AND v.status = 'PUBLISHED' + AND v.display_order = 1 +ON CONFLICT (user_id, video_id) DO NOTHING; + +-- Practice completions +INSERT INTO user_practice_progress (user_id, sub_course_id, question_set_id, completed_at, updated_at) +SELECT 10, qs.owner_id::BIGINT, qs.id, CURRENT_TIMESTAMP - INTERVAL '18 days', CURRENT_TIMESTAMP - INTERVAL '18 days' +FROM question_sets qs +WHERE qs.owner_type = 'SUB_COURSE' + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND qs.owner_id IN (1, 2, 18) +ON CONFLICT (user_id, question_set_id) DO NOTHING; + +INSERT INTO user_practice_progress (user_id, sub_course_id, question_set_id, completed_at, updated_at) +SELECT 11, qs.owner_id::BIGINT, qs.id, CURRENT_TIMESTAMP - INTERVAL '10 days', CURRENT_TIMESTAMP - INTERVAL '10 days' +FROM question_sets qs +WHERE qs.owner_type = 'SUB_COURSE' + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND qs.owner_id IN (1, 2, 25) +ON CONFLICT (user_id, question_set_id) DO NOTHING; + +INSERT INTO user_practice_progress (user_id, sub_course_id, question_set_id, completed_at, updated_at) +SELECT 12, qs.owner_id::BIGINT, qs.id, CURRENT_TIMESTAMP - INTERVAL '7 days', CURRENT_TIMESTAMP - INTERVAL '7 days' +FROM question_sets qs +WHERE qs.owner_type = 'SUB_COURSE' + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND qs.owner_id IN (22) +ON CONFLICT (user_id, question_set_id) DO NOTHING; + +-- Derive sub-course progress from completion tables (same model as runtime auto-aggregate) +WITH target_pairs AS ( + SELECT DISTINCT user_id, sub_course_id + FROM user_sub_course_video_progress + WHERE user_id IN (10, 11, 12) + UNION + SELECT DISTINCT user_id, sub_course_id + FROM user_practice_progress + WHERE user_id IN (10, 11, 12) +), +stats AS ( + SELECT + tp.user_id, + tp.sub_course_id, + (SELECT COUNT(*)::INT + FROM sub_course_videos v + WHERE v.sub_course_id = tp.sub_course_id + AND v.status = 'PUBLISHED') + + + (SELECT COUNT(*)::INT + FROM question_sets qs + WHERE qs.owner_type = 'SUB_COURSE' + AND qs.owner_id = tp.sub_course_id + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED') AS total_items, + (SELECT COUNT(*)::INT + FROM user_sub_course_video_progress uv + JOIN sub_course_videos v ON v.id = uv.video_id + WHERE uv.user_id = tp.user_id + AND uv.sub_course_id = tp.sub_course_id + AND uv.completed_at IS NOT NULL + AND v.status = 'PUBLISHED') + + + (SELECT COUNT(*)::INT + FROM user_practice_progress up + JOIN question_sets qs ON qs.id = up.question_set_id + WHERE up.user_id = tp.user_id + AND up.sub_course_id = tp.sub_course_id + AND up.completed_at IS NOT NULL + AND qs.owner_type = 'SUB_COURSE' + AND qs.owner_id = tp.sub_course_id + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED') AS completed_items + FROM target_pairs tp +) +INSERT INTO user_sub_course_progress (user_id, sub_course_id, status, progress_percentage, started_at, completed_at, updated_at) +SELECT + user_id, + sub_course_id, + CASE + WHEN total_items > 0 AND completed_items >= total_items THEN 'COMPLETED' + WHEN completed_items > 0 THEN 'IN_PROGRESS' + ELSE 'NOT_STARTED' + END AS status, + CASE + WHEN total_items = 0 THEN 0 + ELSE ROUND((completed_items::NUMERIC * 100.0) / total_items::NUMERIC)::SMALLINT + END AS progress_percentage, + CASE WHEN completed_items > 0 THEN CURRENT_TIMESTAMP - INTERVAL '10 days' ELSE NULL END AS started_at, + CASE WHEN total_items > 0 AND completed_items >= total_items THEN CURRENT_TIMESTAMP - INTERVAL '3 days' ELSE NULL END AS completed_at, + CURRENT_TIMESTAMP AS updated_at +FROM stats +ON CONFLICT (user_id, sub_course_id) DO UPDATE SET + status = EXCLUDED.status, + progress_percentage = EXCLUDED.progress_percentage, + started_at = COALESCE(user_sub_course_progress.started_at, EXCLUDED.started_at), + completed_at = EXCLUDED.completed_at, + updated_at = EXCLUDED.updated_at; -- ====================================================== -- Reset sequences to avoid ID conflicts after seeding @@ -352,3 +465,5 @@ SELECT setval(pg_get_serial_sequence('question_set_items', 'id'), COALESCE((SELE SELECT setval(pg_get_serial_sequence('question_set_personas', 'id'), COALESCE((SELECT MAX(id) FROM question_set_personas), 1), true); SELECT setval(pg_get_serial_sequence('sub_course_prerequisites', 'id'), COALESCE((SELECT MAX(id) FROM sub_course_prerequisites), 1), true); SELECT setval(pg_get_serial_sequence('user_sub_course_progress', 'id'), COALESCE((SELECT MAX(id) FROM user_sub_course_progress), 1), true); +SELECT setval(pg_get_serial_sequence('user_sub_course_video_progress', 'id'), COALESCE((SELECT MAX(id) FROM user_sub_course_video_progress), 1), true); +SELECT setval(pg_get_serial_sequence('user_practice_progress', 'id'), COALESCE((SELECT MAX(id) FROM user_practice_progress), 1), true); diff --git a/docs/LEARNER_PROGRESS_TRACKER_ADMIN_INTEGRATION.md b/docs/LEARNER_PROGRESS_TRACKER_ADMIN_INTEGRATION.md index 5580e8a..2089975 100644 --- a/docs/LEARNER_PROGRESS_TRACKER_ADMIN_INTEGRATION.md +++ b/docs/LEARNER_PROGRESS_TRACKER_ADMIN_INTEGRATION.md @@ -15,6 +15,31 @@ This guide explains how to integrate learner sub-course progress tracking into t - **Auth:** Bearer token - **Required permission:** `progress.get_any_user` +## Course-level Summary Endpoint + +- **Method:** `GET` +- **Path:** `/api/v1/admin/users/:userId/progress/courses/:courseId/summary` +- **Auth:** Bearer token +- **Required permission:** `progress.get_any_user` + +### Success Response (`200`) + +```json +{ + "message": "Learner course progress summary retrieved successfully", + "data": { + "course_id": 1, + "learner_user_id": 10, + "overall_progress_percentage": 40, + "total_sub_courses": 5, + "completed_sub_courses": 2, + "in_progress_sub_courses": 1, + "not_started_sub_courses": 2, + "locked_sub_courses": 2 + } +} +``` + ### Path Parameters - `userId` (number): target learner user ID @@ -74,6 +99,31 @@ This guide explains how to integrate learner sub-course progress tracking into t - list is ordered by `display_order` - only active sub-courses are included +## Progress Calculation Model + +Sub-course progress is automatically aggregated from completion records: + +- completed published videos in the sub-course (`user_sub_course_video_progress`) +- completed published practices in the sub-course (`user_practice_progress`) + +Formula: + +- `total_items = published_videos + published_practices` +- `completed_items = completed_videos + completed_practices` +- `progress_percentage = round((completed_items / total_items) * 100)` +- if `total_items = 0`, `progress_percentage = 0` + +Status transitions: + +- `NOT_STARTED` when `completed_items = 0` +- `IN_PROGRESS` when `0 < completed_items < total_items` +- `COMPLETED` when `completed_items >= total_items` and `total_items > 0` + +Auto-recalculation triggers: + +- `POST /api/v1/progress/videos/:id/complete` +- `POST /api/v1/progress/practices/:id/complete` + ## Backend Rollout Steps After pulling this backend change: @@ -90,6 +140,7 @@ After pulling this backend change: 2. Admin selects learner and course. 3. Frontend requests: - `GET /api/v1/admin/users/{userId}/progress/courses/{courseId}` + - `GET /api/v1/admin/users/{userId}/progress/courses/{courseId}/summary` 4. Render returned `data` as ordered progress items. ## Recommended UI Sections diff --git a/internal/ports/course_management.go b/internal/ports/course_management.go index ab68d81..7714e22 100644 --- a/internal/ports/course_management.go +++ b/internal/ports/course_management.go @@ -209,6 +209,7 @@ type ProgressionStore interface { StartSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error + RecalculateSubCourseProgress(ctx context.Context, userID, subCourseID int64) error GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error) GetSubCoursesWithProgressByCourse(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error) diff --git a/internal/repository/progression.go b/internal/repository/progression.go index 67b215a..055eccd 100644 --- a/internal/repository/progression.go +++ b/internal/repository/progression.go @@ -109,6 +109,90 @@ func (s *Store) CompleteSubCourse(ctx context.Context, userID, subCourseID int64 }) } +func (s *Store) RecalculateSubCourseProgress(ctx context.Context, userID, subCourseID int64) error { + const query = ` + WITH totals AS ( + SELECT + (SELECT COUNT(*)::INT + FROM sub_course_videos v + WHERE v.sub_course_id = $2 + AND v.status = 'PUBLISHED') AS total_videos, + (SELECT COUNT(*)::INT + FROM question_sets qs + WHERE qs.owner_type = 'SUB_COURSE' + AND qs.owner_id = $2 + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED') AS total_practices + ), + completed AS ( + SELECT + (SELECT COUNT(*)::INT + FROM user_sub_course_video_progress uv + JOIN sub_course_videos v ON v.id = uv.video_id + WHERE uv.user_id = $1 + AND uv.sub_course_id = $2 + AND uv.completed_at IS NOT NULL + AND v.status = 'PUBLISHED') AS completed_videos, + (SELECT COUNT(*)::INT + FROM user_practice_progress up + JOIN question_sets qs ON qs.id = up.question_set_id + WHERE up.user_id = $1 + AND up.sub_course_id = $2 + AND up.completed_at IS NOT NULL + AND qs.owner_type = 'SUB_COURSE' + AND qs.owner_id = $2 + AND qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED') AS completed_practices + ), + stats AS ( + SELECT + (total_videos + total_practices) AS total_items, + (completed_videos + completed_practices) AS completed_items + FROM totals, completed + ) + INSERT INTO user_sub_course_progress ( + user_id, + sub_course_id, + status, + progress_percentage, + started_at, + completed_at, + updated_at + ) + SELECT + $1, + $2, + CASE + WHEN stats.total_items > 0 AND stats.completed_items >= stats.total_items THEN 'COMPLETED' + WHEN stats.completed_items > 0 THEN 'IN_PROGRESS' + ELSE 'NOT_STARTED' + END, + CASE + WHEN stats.total_items = 0 THEN 0 + ELSE ROUND((stats.completed_items::NUMERIC * 100.0) / stats.total_items::NUMERIC)::SMALLINT + END, + CASE + WHEN stats.completed_items > 0 THEN CURRENT_TIMESTAMP + ELSE NULL + END, + CASE + WHEN stats.total_items > 0 AND stats.completed_items >= stats.total_items THEN CURRENT_TIMESTAMP + ELSE NULL + END, + CURRENT_TIMESTAMP + FROM stats + ON CONFLICT (user_id, sub_course_id) DO UPDATE SET + status = EXCLUDED.status, + progress_percentage = EXCLUDED.progress_percentage, + started_at = COALESCE(user_sub_course_progress.started_at, EXCLUDED.started_at), + completed_at = EXCLUDED.completed_at, + updated_at = EXCLUDED.updated_at; + ` + + _, err := s.conn.Exec(ctx, query, userID, subCourseID) + return err +} + func (s *Store) GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) { row, err := s.queries.GetUserSubCourseProgress(ctx, dbgen.GetUserSubCourseProgressParams{ UserID: userID, diff --git a/internal/services/course_management/progression.go b/internal/services/course_management/progression.go index 95cc81d..194d94a 100644 --- a/internal/services/course_management/progression.go +++ b/internal/services/course_management/progression.go @@ -56,6 +56,10 @@ func (s *Service) CompleteSubCourse(ctx context.Context, userID, subCourseID int return s.progressionStore.CompleteSubCourse(ctx, userID, subCourseID) } +func (s *Service) RecalculateSubCourseProgress(ctx context.Context, userID, subCourseID int64) error { + return s.progressionStore.RecalculateSubCourseProgress(ctx, userID, subCourseID) +} + func (s *Service) GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) { return s.progressionStore.GetUserSubCourseProgress(ctx, userID, subCourseID) } diff --git a/internal/web_server/handlers/course_management.go b/internal/web_server/handlers/course_management.go index 716b6b4..12a47a6 100644 --- a/internal/web_server/handlers/course_management.go +++ b/internal/web_server/handlers/course_management.go @@ -1311,6 +1311,12 @@ func (h *Handler) CompleteSubCourseVideo(c *fiber.Ctx) error { Error: err.Error(), }) } + if err := h.courseMgmtSvc.RecalculateSubCourseProgress(c.Context(), userID, video.SubCourseID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update sub-course progress", + Error: err.Error(), + }) + } return c.JSON(domain.Response{ Message: "Video completed", diff --git a/internal/web_server/handlers/progression_handler.go b/internal/web_server/handlers/progression_handler.go index 3d54b54..c4abc7f 100644 --- a/internal/web_server/handlers/progression_handler.go +++ b/internal/web_server/handlers/progression_handler.go @@ -3,6 +3,7 @@ package handlers import ( "Yimaru-Backend/internal/domain" "errors" + "math" "strconv" "time" @@ -60,6 +61,17 @@ type userProgressRes struct { CompletedAt *time.Time `json:"completed_at,omitempty"` } +type courseProgressSummaryRes struct { + CourseID int64 `json:"course_id"` + LearnerUserID int64 `json:"learner_user_id"` + OverallProgressPercentage int16 `json:"overall_progress_percentage"` + TotalSubCourses int32 `json:"total_sub_courses"` + CompletedSubCourses int32 `json:"completed_sub_courses"` + InProgressSubCourses int32 `json:"in_progress_sub_courses"` + NotStartedSubCourses int32 `json:"not_started_sub_courses"` + LockedSubCourses int32 `json:"locked_sub_courses"` +} + func mapSubCourseProgress(items []domain.SubCourseWithProgress) []subCourseProgressRes { res := make([]subCourseProgressRes, 0, len(items)) for _, item := range items { @@ -447,3 +459,83 @@ func (h *Handler) GetUserCourseProgressForAdmin(c *fiber.Ctx) error { Data: mapSubCourseProgress(items), }) } + +// GetUserCourseProgressSummaryForAdmin godoc +// @Summary Get learner's course progress summary (admin) +// @Description Returns course-level aggregated progress metrics for a target learner +// @Tags progression +// @Produce json +// @Param userId path int true "Learner User ID" +// @Param courseId path int true "Course ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/admin/users/{userId}/progress/courses/{courseId}/summary [get] +func (h *Handler) GetUserCourseProgressSummaryForAdmin(c *fiber.Ctx) error { + targetUserID, err := strconv.ParseInt(c.Params("userId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid user ID", + Error: err.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(), + }) + } + + items, err := h.courseMgmtSvc.GetSubCoursesWithProgress(c.Context(), targetUserID, courseID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to get learner course progress summary", + Error: err.Error(), + }) + } + + var ( + completedCount int32 + inProgressCount int32 + notStartedCount int32 + lockedCount int32 + sumPercentage int64 + ) + + for _, item := range items { + sumPercentage += int64(item.ProgressPercentage) + switch item.ProgressStatus { + case domain.ProgressStatusCompleted: + completedCount++ + case domain.ProgressStatusInProgress: + inProgressCount++ + default: + notStartedCount++ + } + if item.IsLocked { + lockedCount++ + } + } + + totalSubCourses := int32(len(items)) + overall := int16(0) + if totalSubCourses > 0 { + overall = int16(math.Round(float64(sumPercentage) / float64(totalSubCourses))) + } + + return c.JSON(domain.Response{ + Message: "Learner course progress summary retrieved successfully", + Data: courseProgressSummaryRes{ + CourseID: courseID, + LearnerUserID: targetUserID, + OverallProgressPercentage: overall, + TotalSubCourses: totalSubCourses, + CompletedSubCourses: completedCount, + InProgressSubCourses: inProgressCount, + NotStartedSubCourses: notStartedCount, + LockedSubCourses: lockedCount, + }, + }) +} diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 910c205..18f1606 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -1221,6 +1221,18 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error { Error: err.Error(), }) } + if set.OwnerID == nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update sub-course progress", + Error: "practice owner is missing", + }) + } + if err := h.courseMgmtSvc.RecalculateSubCourseProgress(c.Context(), userID, *set.OwnerID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update sub-course progress", + Error: err.Error(), + }) + } return c.JSON(domain.Response{ Message: "Practice completed", diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 9c00138..de52745 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -332,6 +332,7 @@ func (a *App) initAppRoutes() { groupV1.Get("/progress/sub-courses/:id/access", a.authMiddleware, a.RequirePermission("progress.check_access"), h.CheckSubCourseAccess) groupV1.Get("/progress/courses/:courseId", a.authMiddleware, a.RequirePermission("progress.get_course"), h.GetUserCourseProgress) groupV1.Get("/admin/users/:userId/progress/courses/:courseId", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.GetUserCourseProgressForAdmin) + groupV1.Get("/admin/users/:userId/progress/courses/:courseId/summary", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.GetUserCourseProgressSummaryForAdmin) // Ratings groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating)