course level progress tracker implementation

This commit is contained in:
Yared Yemane 2026-03-09 11:20:16 -07:00
parent 74efcd5ec2
commit 515573d56e
6 changed files with 271 additions and 43 deletions

View File

@ -1,26 +1,40 @@
INSERT INTO notifications (user_id, type, level, channel, title, message, payload, is_read, created_at) VALUES INSERT INTO notifications (
-- Student (user_id=10) notifications id, user_id, receiver_type, type, level, channel, title, message, payload, is_read, created_at
(10, 'course_created', 'info', 'in_app', 'New Course Available', 'A new course "Algebra Fundamentals" has been added. Check it out!', '{"course_title": "Algebra Fundamentals", "category": "Mathematics"}', false, now() - interval '30 days'), ) VALUES
(10, 'course_created', 'info', 'in_app', 'New Course Available', 'A new course "English Grammar 101" has been added. Check it out!', '{"course_title": "English Grammar 101", "category": "Language"}', false, now() - interval '25 days'), -- Learner notifications (receiver_type=user, user_id=10)
(10, 'sub_course_created', 'info', 'in_app', 'New Content Available', 'A new sub-course "Linear Equations" has been added.', '{"sub_course_title": "Linear Equations", "course": "Algebra Fundamentals"}', false, now() - interval '24 days'), (1001, 10, 'user', 'course_created', 'info', 'in_app', 'New Course Available', 'A new course "Algebra Fundamentals" has been added. Check it out!', '{"course_title": "Algebra Fundamentals", "category": "Mathematics"}', false, now() - interval '30 days'),
(10, 'video_added', 'info', 'in_app', 'New Video Available', 'A new video "Introduction to Variables" has been added.', '{"video_title": "Introduction to Variables", "sub_course": "Linear Equations"}', false, now() - interval '23 days'), (1002, 10, 'user', 'course_created', 'info', 'in_app', 'New Course Available', 'A new course "English Grammar 101" has been added. Check it out!', '{"course_title": "English Grammar 101", "category": "Language"}', false, now() - interval '25 days'),
(10, 'payment_verified', 'success', 'in_app', 'Payment Successful', 'Your payment has been verified successfully. Your subscription is now active.', '{"plan": "Premium Monthly", "amount": 500}', true, now() - interval '20 days'), (1003, 10, 'user', 'sub_course_created', 'info', 'in_app', 'New Content Available', 'A new sub-course "Linear Equations" has been added.', '{"sub_course_title": "Linear Equations", "course": "Algebra Fundamentals"}', false, now() - interval '24 days'),
(10, 'subscription_activated', 'success', 'in_app', 'Subscription Activated', 'Your Premium Monthly subscription is now active until March 20, 2026.', '{"plan": "Premium Monthly", "expires": "2026-03-20"}', true, now() - interval '20 days'), (1004, 10, 'user', 'video_added', 'info', 'in_app', 'New Video Available', 'A new video "Introduction to Variables" has been added.', '{"video_title": "Introduction to Variables", "sub_course": "Linear Equations"}', false, now() - interval '23 days'),
(10, 'knowledge_level_update', 'info', 'in_app', 'Knowledge Level Updated', 'Your knowledge level has been updated to: Intermediate', '{"previous_level": "Beginner", "new_level": "Intermediate"}', false, now() - interval '15 days'), (1005, 10, 'user', 'payment_verified', 'success', 'in_app', 'Payment Successful', 'Your payment has been verified successfully. Your subscription is now active.', '{"plan": "Premium Monthly", "amount": 500}', true, now() - interval '20 days'),
(10, 'issue_status_updated', 'info', 'in_app', 'Issue Status Updated', 'Your issue "Video not loading on mobile" has been updated to: in_progress', '{"issue_id": 1, "subject": "Video not loading on mobile", "status": "in_progress"}', true, now() - interval '12 days'), (1006, 10, 'user', 'subscription_activated', 'success', 'in_app', 'Subscription Activated', 'Your Premium Monthly subscription is now active until March 20, 2026.', '{"plan": "Premium Monthly", "expires": "2026-03-20"}', true, now() - interval '20 days'),
(10, 'issue_status_updated', 'success', 'in_app', 'Issue Status Updated', 'Your issue "Cannot change profile picture" has been updated to: resolved', '{"issue_id": 3, "subject": "Cannot change profile picture", "status": "resolved"}', true, now() - interval '10 days'), (1007, 10, 'user', 'knowledge_level_update', 'info', 'in_app', 'Knowledge Level Updated', 'Your knowledge level has been updated to: Intermediate', '{"previous_level": "Beginner", "new_level": "Intermediate"}', false, now() - interval '15 days'),
(10, 'course_enrolled', 'success', 'in_app', 'Course Enrolled', 'You have been enrolled in "Biology 101".', '{"course_title": "Biology 101"}', false, now() - interval '8 days'), (1008, 10, 'user', 'issue_status_updated', 'info', 'in_app', 'Issue Status Updated', 'Your issue "Video not loading on mobile" has been updated to: in_progress', '{"issue_id": 1, "subject": "Video not loading on mobile", "status": "in_progress"}', true, now() - interval '12 days'),
(10, 'assessment_assigned', 'info', 'in_app', 'New Assessment Available', 'A new assessment is available for "Algebra Fundamentals".', '{"course": "Algebra Fundamentals", "assessment_type": "quiz"}', false, now() - interval '5 days'), (1009, 10, 'user', 'issue_status_updated', 'success', 'in_app', 'Issue Status Updated', 'Your issue "Cannot change profile picture" has been updated to: resolved', '{"issue_id": 3, "subject": "Cannot change profile picture", "status": "resolved"}', true, now() - interval '10 days'),
(10, 'announcement', 'info', 'in_app', 'Platform Maintenance', 'Scheduled maintenance on Feb 15, 2026 from 2:00 AM - 4:00 AM EAT.', '{"scheduled_at": "2026-02-15T02:00:00+03:00", "duration_hours": 2}', false, now() - interval '2 days'), (1010, 10, 'user', 'course_enrolled', 'success', 'in_app', 'Course Enrolled', 'You have been enrolled in "Biology 101".', '{"course_title": "Biology 101"}', false, now() - interval '8 days'),
(10, 'video_added', 'info', 'in_app', 'New Video Available', 'A new video "Solving Quadratic Equations" has been added.', '{"video_title": "Solving Quadratic Equations", "sub_course": "Quadratics"}', false, now() - interval '1 day'), (1011, 10, 'user', 'assessment_assigned', 'info', 'in_app', 'New Assessment Available', 'A new assessment is available for "Algebra Fundamentals".', '{"course": "Algebra Fundamentals", "assessment_type": "quiz"}', false, now() - interval '5 days'),
(1012, 10, 'user', 'announcement', 'info', 'in_app', 'Platform Maintenance', 'Scheduled maintenance on Feb 15, 2026 from 2:00 AM - 4:00 AM EAT.', '{"scheduled_at": "2026-02-15T02:00:00+03:00", "duration_hours": 2}', false, now() - interval '2 days'),
(1013, 10, 'user', 'video_added', 'info', 'in_app', 'New Video Available', 'A new video "Solving Quadratic Equations" has been added.', '{"video_title": "Solving Quadratic Equations", "sub_course": "Quadratics"}', false, now() - interval '1 day'),
-- Admin (user_id=12) notifications -- Team member notifications (receiver_type=team_member, user_id references team_members.id)
(12, 'issue_created', 'info', 'in_app', 'New Issue Reported', 'A new issue "Video not loading on mobile" has been reported.', '{"issue_id": 1, "subject": "Video not loading on mobile", "reporter_id": 10}', false, now() - interval '14 days'), (1014, 2, 'team_member', 'issue_created', 'info', 'in_app', 'New Issue Reported', 'A new issue "Video not loading on mobile" has been reported.', '{"issue_id": 1, "subject": "Video not loading on mobile", "reporter_id": 10}', false, now() - interval '14 days'),
(12, 'issue_created', 'info', 'in_app', 'New Issue Reported', 'A new issue "Payment confirmation not received" has been reported.', '{"issue_id": 2, "subject": "Payment confirmation not received", "reporter_id": 10}', false, now() - interval '10 days'), (1015, 2, 'team_member', 'issue_created', 'info', 'in_app', 'New Issue Reported', 'A new issue "Payment confirmation not received" has been reported.', '{"issue_id": 2, "subject": "Payment confirmation not received", "reporter_id": 10}', false, now() - interval '10 days'),
(12, 'issue_created', 'info', 'in_app', 'New Issue Reported', 'A new issue "Quiz results not saving" has been reported.', '{"issue_id": 5, "subject": "Quiz results not saving", "reporter_id": 10}', false, now() - interval '5 days'), (1016, 2, 'team_member', 'issue_created', 'info', 'in_app', 'New Issue Reported', 'A new issue "Quiz results not saving" has been reported.', '{"issue_id": 5, "subject": "Quiz results not saving", "reporter_id": 10}', false, now() - interval '5 days'),
(12, 'user_deleted', 'warning', 'in_app', 'User Deleted', 'User ID 99 has been deleted.', '{"deleted_user_id": 99, "deleted_by": 12}', true, now() - interval '18 days'), (1017, 2, 'team_member', 'user_deleted', 'warning', 'in_app', 'User Deleted', 'User ID 99 has been deleted.', '{"deleted_user_id": 99, "deleted_by": 2}', true, now() - interval '18 days'),
(12, 'admin_created', 'info', 'in_app', 'New Admin Created', 'A new admin account has been created for admin@yimaru.com.', '{"admin_email": "admin@yimaru.com"}', true, now() - interval '28 days'), (1018, 2, 'team_member', 'admin_created', 'info', 'in_app', 'New Admin Created', 'A new admin account has been created for admin@yimaru.com.', '{"admin_email": "admin@yimaru.com"}', true, now() - interval '28 days'),
(12, 'team_member_created', 'info', 'in_app', 'New Team Member', 'A new team member has been added.', '{"member_email": "support@yimaru.com", "role": "support"}', true, now() - interval '26 days'), (1019, 2, 'team_member', 'team_member_created','info', 'in_app', 'New Team Member', 'A new team member has been added.', '{"member_email": "support@yimaru.com", "role": "support"}', true, now() - interval '26 days'),
(12, 'system_alert', 'warning', 'in_app', 'High Error Rate Detected', 'The notification delivery failure rate exceeded 5% in the last hour.', '{"failure_rate": 5.2, "window": "1h"}', false, now() - interval '3 days'), (1020, 2, 'team_member', 'system_alert', 'warning', 'in_app', 'High Error Rate Detected', 'The notification delivery failure rate exceeded 5% in the last hour.', '{"failure_rate": 5.2, "window": "1h"}', false, now() - interval '3 days'),
(12, 'announcement', 'info', 'in_app', 'New Student Registrations', '15 new students registered this week.', '{"count": 15, "period": "weekly"}', false, now() - interval '1 day') (1021, 3, 'team_member', 'announcement', 'info', 'in_app', 'Weekly Registration Report','15 new students registered this week.', '{"count": 15, "period": "weekly"}', false, now() - interval '1 day')
ON CONFLICT DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- Scheduled notifications seeds (created_by references users.id)
INSERT INTO scheduled_notifications (
id, channel, title, message, html, scheduled_at, status, target_user_ids, target_role, target_raw,
attempt_count, last_error, processing_started_at, sent_at, cancelled_at, created_by, created_at, updated_at
) VALUES
(2001, 'push', 'Reminder: Continue Your Lesson', 'Pick up where you left off and continue learning today.', NULL, now() + interval '6 hours', 'pending', ARRAY[10,11], NULL, NULL, 0, NULL, NULL, NULL, NULL, 10, now() - interval '1 day', now() - interval '1 day'),
(2002, 'email', 'Weekly Progress Summary', 'Your weekly course progress summary is ready.', '<p>Your weekly course progress summary is ready.</p>', now() + interval '1 day', 'pending', NULL, 'STUDENT', NULL, 0, NULL, NULL, NULL, NULL, 10, now() - interval '1 day', now() - interval '1 day'),
(2003, 'sms', 'Platform Maintenance', 'Scheduled maintenance tonight from 02:00 to 04:00 EAT.', NULL, now() - interval '2 days', 'sent', ARRAY[10,12], NULL, NULL, 1, NULL, now() - interval '2 days' - interval '5 minutes', now() - interval '2 days', NULL, 10, now() - interval '3 days', now() - interval '2 days'),
(2004, 'email', 'Payment Service Alert', 'Some users may experience delayed payment confirmation.', '<p>Some users may experience delayed payment confirmation.</p>', now() - interval '1 day', 'failed', NULL, 'SUPPORT', NULL, 3, 'SMTP temporary outage', now() - interval '1 day' - interval '15 minutes', NULL, NULL, 10, now() - interval '2 days', now() - interval '1 day'),
(2005, 'push', 'Obsolete Campaign', 'This campaign was cancelled by admin.', NULL, now() + interval '2 days', 'cancelled', NULL, NULL, '{"segment":"inactive_users"}'::jsonb, 0, NULL, NULL, NULL, now() - interval '12 hours', 10, now() - interval '1 day', now() - interval '12 hours')
ON CONFLICT (id) DO NOTHING;

View File

@ -81,7 +81,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: runner target: runner
ports: ports:
- "${PORT}:8080" - "${PORT}:${PORT}"
environment: environment:
- DB_URL=postgresql://root:secret@postgres:5432/gh?sslmode=disable - DB_URL=postgresql://root:secret@postgres:5432/gh?sslmode=disable
- MONGO_URI=mongodb://root:secret@mongo:27017 - MONGO_URI=mongodb://root:secret@mongo:27017

View File

@ -0,0 +1,167 @@
# Learner Progress Tracker Admin Integration Guide
This guide explains how to integrate learner sub-course progress tracking into the admin panel using the backend endpoint implemented for admin usage.
## Scope
- Track a specific learner's progress across all sub-courses inside a course.
- Show lock state, progress percentage, and completion timestamps.
- Integrate as a read-focused admin experience.
## New Admin Endpoint
- **Method:** `GET`
- **Path:** `/api/v1/admin/users/:userId/progress/courses/:courseId`
- **Auth:** Bearer token
- **Required permission:** `progress.get_any_user`
### Path Parameters
- `userId` (number): target learner user ID
- `courseId` (number): course ID
### Success Response (`200`)
```json
{
"message": "Learner course progress retrieved successfully",
"data": [
{
"sub_course_id": 11,
"title": "Beginner Conversation Basics",
"description": "Foundational speaking patterns",
"thumbnail": "https://cdn.example.com/sc-11.png",
"display_order": 1,
"level": "BEGINNER",
"progress_status": "IN_PROGRESS",
"progress_percentage": 45,
"started_at": "2026-03-07T09:10:11Z",
"completed_at": null,
"is_locked": false
},
{
"sub_course_id": 12,
"title": "Beginner Listening Drills",
"description": "Daily listening practice",
"thumbnail": null,
"display_order": 2,
"level": "BEGINNER",
"progress_status": "NOT_STARTED",
"progress_percentage": 0,
"started_at": null,
"completed_at": null,
"is_locked": true
}
]
}
```
### Error Responses
- `400`: invalid `userId` or `courseId`
- `401`: missing/invalid token
- `403`: missing `progress.get_any_user`
- `500`: server/database issue
## Data Semantics
- `progress_status` values:
- `NOT_STARTED`
- `IN_PROGRESS`
- `COMPLETED`
- `progress_percentage` is `0..100`
- `is_locked` is computed from unmet sub-course prerequisites for that learner
- list is ordered by `display_order`
- only active sub-courses are included
## Backend Rollout Steps
After pulling this backend change:
1. Restart backend service.
2. Sync permissions:
- `POST /api/v1/rbac/permissions/sync`
3. Ensure admin role includes `progress.get_any_user`.
- If your system uses explicit role-permission assignment, update role permissions after sync.
## Admin Panel Integration Flow
1. User opens learner progress screen.
2. Admin selects learner and course.
3. Frontend requests:
- `GET /api/v1/admin/users/{userId}/progress/courses/{courseId}`
4. Render returned `data` as ordered progress items.
## Recommended UI Sections
- Header:
- learner identity
- selected course
- Metrics row:
- total sub-courses
- completed count
- in-progress count
- locked count
- average progress percentage
- Ordered list/table:
- sub-course title
- level
- status badge
- progress bar
- locked icon
- started/completed timestamps
## Frontend Mapping Example
For each item:
- `statusLabel = progress_status`
- `isCompleted = progress_status === "COMPLETED"`
- `isInProgress = progress_status === "IN_PROGRESS"`
- `isNotStarted = progress_status === "NOT_STARTED"`
- `canOpenDetails = !is_locked`
## Suggested API Client Contract
```ts
type LearnerCourseProgressItem = {
sub_course_id: number;
title: string;
description?: string | null;
thumbnail?: string | null;
display_order: number;
level: string;
progress_status: "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED";
progress_percentage: number;
started_at?: string | null;
completed_at?: string | null;
is_locked: boolean;
};
type LearnerCourseProgressResponse = {
message: string;
data: LearnerCourseProgressItem[];
};
```
## Example Request
```bash
curl -X GET "http://localhost:8432/api/v1/admin/users/7/progress/courses/3" \
-H "Authorization: Bearer <ACCESS_TOKEN>"
```
## Operational Notes
- This endpoint is intended for admin/super-admin workflows.
- Existing learner self endpoint remains available:
- `GET /api/v1/progress/courses/:courseId`
- Do not expose `progress.get_any_user` to learner-facing roles.
## QA Checklist
- valid admin token + permission returns `200`
- token without permission returns `403`
- invalid `userId` or `courseId` returns `400`
- locked sub-courses correctly show `is_locked: true`
- ordering in UI follows `display_order`

View File

@ -195,6 +195,7 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "progress.complete", Name: "Complete Sub-course", Description: "Complete a sub-course", GroupName: "Progress"}, {Key: "progress.complete", Name: "Complete Sub-course", Description: "Complete a sub-course", GroupName: "Progress"},
{Key: "progress.check_access", Name: "Check Access", Description: "Check sub-course access", GroupName: "Progress"}, {Key: "progress.check_access", Name: "Check Access", Description: "Check sub-course access", GroupName: "Progress"},
{Key: "progress.get_course", Name: "Get Course Progress", Description: "Get user course progress", GroupName: "Progress"}, {Key: "progress.get_course", Name: "Get Course Progress", Description: "Get user course progress", GroupName: "Progress"},
{Key: "progress.get_any_user", Name: "Get Any User Course Progress", Description: "Get course progress for any user (admin)", GroupName: "Progress"},
// Ratings // Ratings
{Key: "ratings.submit", Name: "Submit Rating", Description: "Submit a rating", GroupName: "Ratings"}, {Key: "ratings.submit", Name: "Submit Rating", Description: "Submit a rating", GroupName: "Ratings"},
@ -289,7 +290,7 @@ var DefaultRolePermissions = map[string][]string{
"subcourse_prerequisites.add", "subcourse_prerequisites.list", "subcourse_prerequisites.remove", "subcourse_prerequisites.add", "subcourse_prerequisites.list", "subcourse_prerequisites.remove",
// Progress // Progress
"progress.start", "progress.update", "progress.complete", "progress.check_access", "progress.get_course", "progress.start", "progress.update", "progress.complete", "progress.check_access", "progress.get_course", "progress.get_any_user",
// Ratings // Ratings
"ratings.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete", "ratings.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete",

View File

@ -60,6 +60,26 @@ type userProgressRes struct {
CompletedAt *time.Time `json:"completed_at,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"`
} }
func mapSubCourseProgress(items []domain.SubCourseWithProgress) []subCourseProgressRes {
res := make([]subCourseProgressRes, 0, len(items))
for _, item := range items {
res = append(res, subCourseProgressRes{
SubCourseID: item.SubCourseID,
Title: item.Title,
Description: item.Description,
Thumbnail: item.Thumbnail,
DisplayOrder: item.DisplayOrder,
Level: item.Level,
ProgressStatus: string(item.ProgressStatus),
ProgressPercentage: item.ProgressPercentage,
StartedAt: item.StartedAt,
CompletedAt: item.CompletedAt,
IsLocked: item.IsLocked,
})
}
return res
}
// --- Prerequisite Handlers (admin) --- // --- Prerequisite Handlers (admin) ---
// AddSubCoursePrerequisite godoc // AddSubCoursePrerequisite godoc
@ -380,25 +400,50 @@ func (h *Handler) GetUserCourseProgress(c *fiber.Ctx) error {
}) })
} }
var res []subCourseProgressRes return c.JSON(domain.Response{
for _, item := range items { Message: "Course progress retrieved successfully",
res = append(res, subCourseProgressRes{ Data: mapSubCourseProgress(items),
SubCourseID: item.SubCourseID, })
Title: item.Title, }
Description: item.Description,
Thumbnail: item.Thumbnail, // GetUserCourseProgressForAdmin godoc
DisplayOrder: item.DisplayOrder, // @Summary Get learner's course progress (admin)
Level: item.Level, // @Description Returns a target learner's progress for all sub-courses in a course, including lock status
ProgressStatus: string(item.ProgressStatus), // @Tags progression
ProgressPercentage: item.ProgressPercentage, // @Produce json
StartedAt: item.StartedAt, // @Param userId path int true "Learner User ID"
CompletedAt: item.CompletedAt, // @Param courseId path int true "Course ID"
IsLocked: item.IsLocked, // @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/users/{userId}/progress/courses/{courseId} [get]
func (h *Handler) GetUserCourseProgressForAdmin(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",
Error: err.Error(),
}) })
} }
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Course progress retrieved successfully", Message: "Learner course progress retrieved successfully",
Data: res, Data: mapSubCourseProgress(items),
}) })
} }

View File

@ -331,6 +331,7 @@ func (a *App) initAppRoutes() {
groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequirePermission("progress.update"), h.CompletePractice) groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequirePermission("progress.update"), h.CompletePractice)
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)
// Ratings // Ratings
groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating) groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating)