Compare commits
No commits in common. "d55873909714da4cf3d569b92b6e482dc629a307" and "74efcd5ec2bdf06ab0ead32ccab063a865c3819d" have entirely different histories.
d558739097
...
74efcd5ec2
|
|
@ -1,40 +1,26 @@
|
||||||
INSERT INTO notifications (
|
INSERT INTO notifications (user_id, type, level, channel, title, message, payload, is_read, created_at) VALUES
|
||||||
id, user_id, receiver_type, type, level, channel, title, message, payload, is_read, created_at
|
-- Student (user_id=10) notifications
|
||||||
) VALUES
|
(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'),
|
||||||
-- Learner notifications (receiver_type=user, user_id=10)
|
(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'),
|
||||||
(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, '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'),
|
||||||
(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, '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'),
|
||||||
(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, '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'),
|
||||||
(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, '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'),
|
||||||
(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, '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'),
|
||||||
(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', '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'),
|
||||||
(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, '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'),
|
||||||
(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, 'course_enrolled', 'success', 'in_app', 'Course Enrolled', 'You have been enrolled in "Biology 101".', '{"course_title": "Biology 101"}', false, now() - interval '8 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, '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'),
|
||||||
(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, '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'),
|
||||||
(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'),
|
(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'),
|
||||||
(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'),
|
|
||||||
|
|
||||||
-- Team member notifications (receiver_type=team_member, user_id references team_members.id)
|
-- Admin (user_id=12) notifications
|
||||||
(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 "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'),
|
(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'),
|
||||||
(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, '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'),
|
(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'),
|
||||||
(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, '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'),
|
(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'),
|
||||||
(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, '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')
|
(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 (id) DO NOTHING;
|
ON CONFLICT 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;
|
|
||||||
|
|
|
||||||
|
|
@ -305,151 +305,38 @@ 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;
|
||||||
|
|
||||||
-- ======================================================
|
-- ======================================================
|
||||||
-- Completion-driven progress seed (auto-aggregate model)
|
-- User Sub-course Progress
|
||||||
-- Seed video/practice completion records, then derive sub-course progress
|
-- Simulate realistic student progress for admin panel
|
||||||
-- ======================================================
|
-- ======================================================
|
||||||
|
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),
|
||||||
|
|
||||||
-- Video completions
|
-- Student 10: started Flutter
|
||||||
INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at)
|
(10, 18, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '15 days', CURRENT_TIMESTAMP - INTERVAL '8 days'),
|
||||||
SELECT 10, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '20 days', CURRENT_TIMESTAMP - INTERVAL '20 days'
|
(10, 19, 'IN_PROGRESS', 40, CURRENT_TIMESTAMP - INTERVAL '8 days', NULL),
|
||||||
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;
|
|
||||||
|
|
||||||
INSERT INTO user_sub_course_video_progress (user_id, sub_course_id, video_id, completed_at, updated_at)
|
-- Student 11 (Abebe): completed Python, started JavaScript
|
||||||
SELECT 10, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '8 days', CURRENT_TIMESTAMP - INTERVAL '8 days'
|
(11, 1, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '45 days', CURRENT_TIMESTAMP - INTERVAL '35 days'),
|
||||||
FROM sub_course_videos v
|
(11, 2, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '35 days', CURRENT_TIMESTAMP - INTERVAL '25 days'),
|
||||||
WHERE v.sub_course_id = 19
|
(11, 3, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '25 days', CURRENT_TIMESTAMP - INTERVAL '15 days'),
|
||||||
AND v.status = 'PUBLISHED'
|
(11, 4, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '15 days', CURRENT_TIMESTAMP - INTERVAL '5 days'),
|
||||||
AND v.display_order = 1
|
(11, 5, 'IN_PROGRESS', 30, CURRENT_TIMESTAMP - INTERVAL '5 days', NULL),
|
||||||
ON CONFLICT (user_id, video_id) DO NOTHING;
|
(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)
|
-- Student 11: Docker course
|
||||||
SELECT 11, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '25 days', CURRENT_TIMESTAMP - INTERVAL '25 days'
|
(11, 25, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '10 days', CURRENT_TIMESTAMP - INTERVAL '3 days'),
|
||||||
FROM sub_course_videos v
|
(11, 26, 'IN_PROGRESS', 20, CURRENT_TIMESTAMP - INTERVAL '3 days', NULL),
|
||||||
WHERE v.sub_course_id IN (1, 2, 25)
|
|
||||||
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)
|
-- Student 12 (Sara): just started
|
||||||
SELECT 11, v.sub_course_id, v.id, CURRENT_TIMESTAMP - INTERVAL '3 days', CURRENT_TIMESTAMP - INTERVAL '3 days'
|
(12, 1, 'IN_PROGRESS', 25, CURRENT_TIMESTAMP - INTERVAL '7 days', NULL),
|
||||||
FROM sub_course_videos v
|
(12, 18, 'IN_PROGRESS', 10, CURRENT_TIMESTAMP - INTERVAL '3 days', NULL),
|
||||||
WHERE v.sub_course_id = 26
|
(12, 22, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '14 days', CURRENT_TIMESTAMP - INTERVAL '7 days'),
|
||||||
AND v.status = 'PUBLISHED'
|
(12, 23, 'IN_PROGRESS', 60, CURRENT_TIMESTAMP - INTERVAL '7 days', NULL)
|
||||||
ON CONFLICT (user_id, video_id) DO NOTHING;
|
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 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
|
||||||
|
|
@ -465,5 +352,3 @@ 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);
|
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ services:
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: runner
|
target: runner
|
||||||
ports:
|
ports:
|
||||||
- "${PORT}:${PORT}"
|
- "${PORT}:8080"
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
# 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`
|
|
||||||
|
|
@ -209,7 +209,6 @@ 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,90 +109,6 @@ 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,10 +56,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,6 @@ 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"},
|
||||||
|
|
@ -290,7 +289,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.get_any_user",
|
"progress.start", "progress.update", "progress.complete", "progress.check_access", "progress.get_course",
|
||||||
|
|
||||||
// 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",
|
||||||
|
|
|
||||||
|
|
@ -1311,12 +1311,6 @@ 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,7 +3,6 @@ package handlers
|
||||||
import (
|
import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -61,37 +60,6 @@ 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 {
|
|
||||||
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
|
||||||
|
|
@ -412,130 +380,25 @@ 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{
|
return c.JSON(domain.Response{
|
||||||
Message: "Course progress retrieved successfully",
|
Message: "Course progress retrieved successfully",
|
||||||
Data: mapSubCourseProgress(items),
|
Data: res,
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: "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,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1221,18 +1221,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -331,8 +331,6 @@ 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)
|
|
||||||
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