Compare commits

..

2 Commits

Author SHA1 Message Date
d558739097 preogress tracker fix 2026-03-10 02:35:13 -07:00
515573d56e course level progress tracker implementation 2026-03-09 11:20:16 -07:00
12 changed files with 664 additions and 70 deletions

View File

@ -1,26 +1,40 @@
INSERT INTO notifications (user_id, type, level, channel, title, message, payload, is_read, created_at) VALUES
-- Student (user_id=10) notifications
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(10, 'course_enrolled', 'success', 'in_app', 'Course Enrolled', 'You have been enrolled in "Biology 101".', '{"course_title": "Biology 101"}', false, now() - interval '8 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'),
(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'),
(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'),
INSERT INTO notifications (
id, user_id, receiver_type, type, level, channel, title, message, payload, is_read, created_at
) VALUES
-- Learner notifications (receiver_type=user, user_id=10)
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(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
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(12, 'announcement', 'info', 'in_app', 'New Student Registrations', '15 new students registered this week.', '{"count": 15, "period": "weekly"}', false, now() - interval '1 day')
ON CONFLICT DO NOTHING;
-- Team member notifications (receiver_type=team_member, user_id references team_members.id)
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(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'),
(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 (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

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

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

View File

@ -0,0 +1,218 @@
# 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`
## 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
- `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
## 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:
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}`
- `GET /api/v1/admin/users/{userId}/progress/courses/{courseId}/summary`
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

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

@ -195,6 +195,7 @@ var AllPermissions = []domain.PermissionSeed{
{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.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
{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",
// 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.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete",

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,37 @@ 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 {
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) ---
// AddSubCoursePrerequisite godoc
@ -380,25 +412,130 @@ func (h *Handler) GetUserCourseProgress(c *fiber.Ctx) error {
})
}
var res []subCourseProgressRes
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 c.JSON(domain.Response{
Message: "Course progress retrieved successfully",
Data: mapSubCourseProgress(items),
})
}
// GetUserCourseProgressForAdmin godoc
// @Summary Get learner's course progress (admin)
// @Description Returns a target learner's progress for all sub-courses in a course, including lock status
// @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} [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{
Message: "Course progress retrieved successfully",
Data: res,
Message: "Learner course progress retrieved successfully",
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

@ -331,6 +331,8 @@ func (a *App) initAppRoutes() {
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/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)