preogress tracker fix

This commit is contained in:
Yared Yemane 2026-03-10 02:35:13 -07:00
parent 515573d56e
commit d558739097
9 changed files with 393 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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