preogress tracker fix
This commit is contained in:
parent
515573d56e
commit
d558739097
|
|
@ -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;
|
ON CONFLICT (sub_course_id, prerequisite_sub_course_id) DO NOTHING;
|
||||||
|
|
||||||
-- ======================================================
|
-- ======================================================
|
||||||
-- User Sub-course Progress
|
-- Completion-driven progress seed (auto-aggregate model)
|
||||||
-- Simulate realistic student progress for admin panel
|
-- 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
|
-- Video completions
|
||||||
(10, 18, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '15 days', CURRENT_TIMESTAMP - INTERVAL '8 days'),
|
INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at)
|
||||||
(10, 19, 'IN_PROGRESS', 40, CURRENT_TIMESTAMP - INTERVAL '8 days', NULL),
|
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
|
INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at)
|
||||||
(11, 1, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '45 days', CURRENT_TIMESTAMP - INTERVAL '35 days'),
|
SELECT 10, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '8 days', CURRENT_TIMESTAMP - INTERVAL '8 days'
|
||||||
(11, 2, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '35 days', CURRENT_TIMESTAMP - INTERVAL '25 days'),
|
FROM sub_course_videos v
|
||||||
(11, 3, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '25 days', CURRENT_TIMESTAMP - INTERVAL '15 days'),
|
WHERE v.sub_course_id = 19
|
||||||
(11, 4, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '15 days', CURRENT_TIMESTAMP - INTERVAL '5 days'),
|
AND v.status = 'PUBLISHED'
|
||||||
(11, 5, 'IN_PROGRESS', 30, CURRENT_TIMESTAMP - INTERVAL '5 days', NULL),
|
AND v.display_order = 1
|
||||||
(11, 6, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '20 days', CURRENT_TIMESTAMP - INTERVAL '10 days'),
|
ON CONFLICT (user_id, video_id) DO NOTHING;
|
||||||
(11, 7, 'IN_PROGRESS', 50, CURRENT_TIMESTAMP - INTERVAL '10 days', NULL),
|
|
||||||
|
|
||||||
-- Student 11: Docker course
|
INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at)
|
||||||
(11, 25, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '10 days', CURRENT_TIMESTAMP - INTERVAL '3 days'),
|
SELECT 11, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '25 days', CURRENT_TIMESTAMP - INTERVAL '25 days'
|
||||||
(11, 26, 'IN_PROGRESS', 20, CURRENT_TIMESTAMP - INTERVAL '3 days', NULL),
|
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
|
INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at)
|
||||||
(12, 1, 'IN_PROGRESS', 25, CURRENT_TIMESTAMP - INTERVAL '7 days', NULL),
|
SELECT 11, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '3 days', CURRENT_TIMESTAMP - INTERVAL '3 days'
|
||||||
(12, 18, 'IN_PROGRESS', 10, CURRENT_TIMESTAMP - INTERVAL '3 days', NULL),
|
FROM sub_course_videos v
|
||||||
(12, 22, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '14 days', CURRENT_TIMESTAMP - INTERVAL '7 days'),
|
WHERE v.sub_course_id = 26
|
||||||
(12, 23, 'IN_PROGRESS', 60, CURRENT_TIMESTAMP - INTERVAL '7 days', NULL)
|
AND v.status = 'PUBLISHED'
|
||||||
ON CONFLICT (user_id, sub_course_id) DO NOTHING;
|
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
|
-- 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('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('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_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);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,31 @@ This guide explains how to integrate learner sub-course progress tracking into t
|
||||||
- **Auth:** Bearer token
|
- **Auth:** Bearer token
|
||||||
- **Required permission:** `progress.get_any_user`
|
- **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
|
### Path Parameters
|
||||||
|
|
||||||
- `userId` (number): target learner user ID
|
- `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`
|
- list is ordered by `display_order`
|
||||||
- only active sub-courses are included
|
- 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
|
## Backend Rollout Steps
|
||||||
|
|
||||||
After pulling this backend change:
|
After pulling this backend change:
|
||||||
|
|
@ -90,6 +140,7 @@ After pulling this backend change:
|
||||||
2. Admin selects learner and course.
|
2. Admin selects learner and course.
|
||||||
3. Frontend requests:
|
3. Frontend requests:
|
||||||
- `GET /api/v1/admin/users/{userId}/progress/courses/{courseId}`
|
- `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.
|
4. Render returned `data` as ordered progress items.
|
||||||
|
|
||||||
## Recommended UI Sections
|
## Recommended UI Sections
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,7 @@ type ProgressionStore interface {
|
||||||
StartSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error)
|
StartSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error)
|
||||||
UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error
|
UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error
|
||||||
CompleteSubCourse(ctx context.Context, userID, subCourseID int64) 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)
|
GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error)
|
||||||
GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error)
|
GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error)
|
||||||
GetSubCoursesWithProgressByCourse(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error)
|
GetSubCoursesWithProgressByCourse(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func (s *Store) GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) {
|
||||||
row, err := s.queries.GetUserSubCourseProgress(ctx, dbgen.GetUserSubCourseProgressParams{
|
row, err := s.queries.GetUserSubCourseProgress(ctx, dbgen.GetUserSubCourseProgressParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ func (s *Service) CompleteSubCourse(ctx context.Context, userID, subCourseID int
|
||||||
return s.progressionStore.CompleteSubCourse(ctx, userID, subCourseID)
|
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) {
|
func (s *Service) GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) {
|
||||||
return s.progressionStore.GetUserSubCourseProgress(ctx, userID, subCourseID)
|
return s.progressionStore.GetUserSubCourseProgress(ctx, userID, subCourseID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1311,6 +1311,12 @@ func (h *Handler) CompleteSubCourseVideo(c *fiber.Ctx) error {
|
||||||
Error: err.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{
|
return c.JSON(domain.Response{
|
||||||
Message: "Video completed",
|
Message: "Video completed",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package handlers
|
||||||
import (
|
import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -60,6 +61,17 @@ type userProgressRes struct {
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
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 {
|
func mapSubCourseProgress(items []domain.SubCourseWithProgress) []subCourseProgressRes {
|
||||||
res := make([]subCourseProgressRes, 0, len(items))
|
res := make([]subCourseProgressRes, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
|
@ -447,3 +459,83 @@ func (h *Handler) GetUserCourseProgressForAdmin(c *fiber.Ctx) error {
|
||||||
Data: mapSubCourseProgress(items),
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1221,6 +1221,18 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
|
||||||
Error: err.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{
|
return c.JSON(domain.Response{
|
||||||
Message: "Practice completed",
|
Message: "Practice completed",
|
||||||
|
|
|
||||||
|
|
@ -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/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("/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", 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
|
// Ratings
|
||||||
groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating)
|
groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user