Compare commits

...

2 Commits

Author SHA1 Message Date
894e18bcae removed all unnecessary data seed 2026-04-10 03:32:07 -07:00
7613eb583a new course management hierarchy 2026-04-10 03:06:30 -07:00
58 changed files with 3438 additions and 14139 deletions

View File

@ -136,190 +136,6 @@ VALUES
)
ON CONFLICT (id) DO NOTHING;
-- Ensure seeded admin has full panel permissions in legacy team_members.permissions JSON.
-- RBAC permissions are managed separately, but this keeps seed behavior consistent.
UPDATE team_members
SET permissions = '["*"]'::jsonb
WHERE id = 2 OR email = 'admin@yimaru.com';
-- ======================================================
-- Global Settings (LMS)
-- ======================================================
INSERT INTO global_settings (key, value)
VALUES
('platform_name', 'Yimaru LMS'),
('default_language', 'en'),
('allow_self_signup', 'true'),
('otp_expiry_minutes', '5'),
('certificate_enabled', 'true'),
('max_courses_per_instructor', '50')
ON CONFLICT (key) DO NOTHING;
-- ======================================================
-- ======================================================
-- Questions - Level A2 (EASY)
-- ======================================================
INSERT INTO questions (id, question_text, question_type, difficulty_level, points, status)
VALUES
(1, 'What would you say to greet someone before lunchtime?', 'MCQ', 'EASY', 1, 'PUBLISHED'),
(2, 'Which question is correct to ask about your routine?', 'MCQ', 'EASY', 1, 'PUBLISHED'),
(3, 'She ___ like pizza.', 'MCQ', 'EASY', 1, 'PUBLISHED'),
(4, 'I usually go to school and start class ____ eight o''clock.', 'MCQ', 'EASY', 1, 'PUBLISHED'),
(5, 'Someone says, "Here is the book you asked for." What is the best response?', 'MCQ', 'EASY', 1, 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_options (question_id, option_text, option_order, is_correct)
VALUES
-- Q1
(1, 'Good morning.', 1, TRUE),
(1, 'How do you do?', 2, FALSE),
(1, 'Good afternoon.', 3, FALSE),
(1, 'Goodbye.', 4, FALSE),
-- Q2
(2, 'What time you wake up?', 1, FALSE),
(2, 'What time do you wake up?', 2, TRUE),
(2, 'What time are you wake up?', 3, FALSE),
(2, 'What time waking you?', 4, FALSE),
-- Q3
(3, 'do not', 1, FALSE),
(3, 'not', 2, FALSE),
(3, 'is not', 3, FALSE),
(3, 'does not', 4, TRUE),
-- Q4
(4, 'about', 1, FALSE),
(4, 'on', 2, FALSE),
(4, 'at', 3, TRUE),
(4, 'in', 4, FALSE),
-- Q5
(5, 'Never mind.', 1, FALSE),
(5, 'Really?', 2, FALSE),
(5, 'What a pity!', 3, FALSE),
(5, 'Thank you.', 4, TRUE);
-- ======================================================
-- Questions - Level B1 (MEDIUM)
-- ======================================================
INSERT INTO questions (id, question_text, question_type, difficulty_level, points, status)
VALUES
(6, 'How do you introduce your friend to another person?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED'),
(7, 'How would you ask for the price of an item in a shop?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED'),
(8, 'Which sentence correctly gives simple directions?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED'),
(9, 'The watch shows 10:50, but the real time is 10:45. What can you say?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED'),
(10, 'Which instruction is correct when giving directions?', 'MCQ', 'MEDIUM', 1, 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_options (question_id, option_text, option_order, is_correct)
VALUES
-- Q6
(6, 'Hello, my name is Samson.', 1, FALSE),
(6, 'Good morning. Nice to meet you.', 2, FALSE),
(6, 'Let me introduce myself to my friend.', 3, FALSE),
(6, 'This is my friend, Samson.', 4, TRUE),
-- Q7
(7, 'How many are these?', 1, FALSE),
(7, 'What is this?', 2, FALSE),
(7, 'How much is this?', 3, TRUE),
(7, 'Where is the nearest shop?', 4, FALSE),
-- Q8
(8, 'Thank you very much for asking.', 1, FALSE),
(8, 'Turn left and walk two blocks.', 2, TRUE),
(8, 'Why don''t you eat out.', 3, FALSE),
(8, 'Take the bus to the park.', 4, FALSE),
-- Q9
(9, 'My watch is slow.', 1, TRUE),
(9, 'My watch is late.', 2, FALSE),
(9, 'My watch is fast.', 3, FALSE),
(9, 'My watch is early.', 4, FALSE),
-- Q10
(10, 'Turn left.', 1, TRUE),
(10, 'Turn on left.', 2, FALSE),
(10, 'Turn left side.', 3, FALSE),
(10, 'Turn to straight.', 4, FALSE);
-- ======================================================
-- Questions - Level B2 (HARD)
-- ======================================================
INSERT INTO questions (id, question_text, question_type, difficulty_level, points, status)
VALUES
(11, 'What is the most polite way to ask to speak to someone on the phone?', 'MCQ', 'HARD', 1, 'PUBLISHED'),
(12, 'How do you correctly state the age of a person who is 30 years old?', 'MCQ', 'HARD', 1, 'PUBLISHED'),
(13, 'When asking for help with a new Yimaru App feature, which option is most appropriate?', 'MCQ', 'HARD', 1, 'PUBLISHED'),
(14, 'Which word has the unvoiced "th" sound?', 'MCQ', 'HARD', 1, 'PUBLISHED'),
(15, 'Which sentence sounds like a warning, not friendly advice?', 'MCQ', 'HARD', 1, 'PUBLISHED'),
(16, 'What does this sentence mean? "I will definitely be there on time."', 'MCQ', 'HARD', 1, 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_options (question_id, option_text, option_order, is_correct)
VALUES
-- Q11
(11, 'May I speak to Mr. Tesfaye, please?', 1, TRUE),
(11, 'Can I talk to Mr. Tesfaye?', 2, FALSE),
(11, 'Is Mr. Tesfaye there?', 3, FALSE),
(11, 'I want to talk to Mr. Tesfaye.', 4, FALSE),
-- Q12
(12, 'He is thirty years.', 1, FALSE),
(12, 'He has thirty years.', 2, FALSE),
(12, 'He has thirty years old.', 3, FALSE),
(12, 'He is thirty.', 4, TRUE),
-- Q13
(13, 'Are you familiar with how this feature works?', 1, FALSE),
(13, 'Could you walk me through how this feature works?', 2, TRUE),
(13, 'I believe I understand how this feature works.', 3, FALSE),
(13, 'I''ve tried similar features before.', 4, FALSE),
-- Q14
(14, 'That', 1, FALSE),
(14, 'They', 2, FALSE),
(14, 'These', 3, FALSE),
(14, 'Three', 4, TRUE),
-- Q15
(15, 'You might want to plan your time better.', 1, FALSE),
(15, 'If I were you, I''d start earlier.', 2, FALSE),
(15, 'You''d better meet the deadline this time.', 3, TRUE),
(15, 'Why don''t you try using a planner?', 4, FALSE),
-- Q16
(16, 'The speaker is unsure about arriving.', 1, FALSE),
(16, 'The speaker is promising to arrive on time.', 2, TRUE),
(16, 'The speaker might arrive late.', 3, FALSE),
(16, 'The speaker has already arrived.', 4, FALSE);
-- ======================================================
-- Initial Assessment Question Set
-- ======================================================
INSERT INTO question_sets (id, title, description, set_type, owner_type, status)
VALUES
(1, 'Initial Assessment', 'Default initial assessment for new users', 'INITIAL_ASSESSMENT', 'STANDALONE', 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_set_items (set_id, question_id, display_order)
VALUES
(1, 1, 1), (1, 2, 2), (1, 3, 3), (1, 4, 4), (1, 5, 5),
(1, 6, 6), (1, 7, 7), (1, 8, 8), (1, 9, 9), (1, 10, 10),
(1, 11, 11), (1, 12, 12), (1, 13, 13), (1, 14, 14), (1, 15, 15), (1, 16, 16)
ON CONFLICT (set_id, question_id) DO NOTHING;
-- ======================================================
-- Course Management seed data removed intentionally.
-- Course/category/sub-course/video/practice/question-set fixtures
-- are no longer seeded from this baseline script.
-- ======================================================
-- ======================================================
-- Team Members / Admin Panel Users (login via /api/v1/team/login)
-- Credentials: email + password@123
@ -471,3 +287,8 @@ VALUES
CURRENT_TIMESTAMP
)
ON CONFLICT (id) DO NOTHING;
-- Legacy team_members row may pre-exist; align admin permissions with seed expectations.
UPDATE team_members
SET permissions = '["*"]'::jsonb
WHERE id = 2 OR email = 'admin@yimaru.com';

View File

@ -1,108 +1,25 @@
-- ======================================================
-- Reset sequences for LMS tables (PostgreSQL)
-- ======================================================
-- Reset sequences for tables touched by login-only seed (PostgreSQL)
-- users.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('users', 'id'),
COALESCE((SELECT MAX(id) FROM users), 1),
true
);
-- questions.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('questions', 'id'),
COALESCE((SELECT MAX(id) FROM questions), 1),
pg_get_serial_sequence('team_members', 'id'),
COALESCE((SELECT MAX(id) FROM team_members), 1),
true
);
-- question_options.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('question_options', 'id'),
COALESCE((SELECT MAX(id) FROM question_options), 1),
true
);
-- question_short_answers.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('question_short_answers', 'id'),
COALESCE((SELECT MAX(id) FROM question_short_answers), 1),
true
);
-- question_sets.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('question_sets', 'id'),
COALESCE((SELECT MAX(id) FROM question_sets), 1),
true
);
-- question_set_items.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('question_set_items', 'id'),
COALESCE((SELECT MAX(id) FROM question_set_items), 1),
true
);
-- refresh_tokens.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('refresh_tokens', 'id'),
COALESCE((SELECT MAX(id) FROM refresh_tokens), 1),
true
);
-- otps.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('otps', 'id'),
COALESCE((SELECT MAX(id) FROM otps), 1),
true
);
-- notifications.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('notifications', 'id'),
COALESCE((SELECT MAX(id) FROM notifications), 1),
true
);
-- reported_issues.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('reported_issues', 'id'),
COALESCE((SELECT MAX(id) FROM reported_issues), 1),
true
);
-- course_categories.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('course_categories', 'id'),
COALESCE((SELECT MAX(id) FROM course_categories), 1),
true
);
-- courses.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('courses', 'id'),
COALESCE((SELECT MAX(id) FROM courses), 1),
true
);
-- sub_courses.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('sub_courses', 'id'),
COALESCE((SELECT MAX(id) FROM sub_courses), 1),
true
);
-- sub_course_videos.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('sub_course_videos', 'id'),
COALESCE((SELECT MAX(id) FROM sub_course_videos), 1),
true
);
-- question_set_personas.id (BIGSERIAL)
SELECT setval(
pg_get_serial_sequence('question_set_personas', 'id'),
COALESCE((SELECT MAX(id) FROM question_set_personas), 1),
true
);

View File

@ -1,31 +1 @@
INSERT INTO activity_logs (actor_id, actor_role, action, resource_type, resource_id, message, metadata, ip_address, user_agent, created_at) VALUES
(1, 'SUPER_ADMIN', 'CATEGORY_CREATED', 'CATEGORY', 1, 'Created course category: Mathematics', '{"name": "Mathematics"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '30 days'),
(1, 'SUPER_ADMIN', 'CATEGORY_CREATED', 'CATEGORY', 2, 'Created course category: Science', '{"name": "Science"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '29 days'),
(1, 'SUPER_ADMIN', 'CATEGORY_CREATED', 'CATEGORY', 3, 'Created course category: Language Arts', '{"name": "Language Arts"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '28 days'),
(1, 'SUPER_ADMIN', 'COURSE_CREATED', 'COURSE', 1, 'Created course: Algebra Fundamentals', '{"title": "Algebra Fundamentals", "category_id": 1}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '27 days'),
(1, 'SUPER_ADMIN', 'COURSE_CREATED', 'COURSE', 2, 'Created course: Biology 101', '{"title": "Biology 101", "category_id": 2}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '26 days'),
(2, 'ADMIN', 'COURSE_CREATED', 'COURSE', 3, 'Created course: English Grammar', '{"title": "English Grammar", "category_id": 3}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '25 days'),
(1, 'SUPER_ADMIN', 'SUB_COURSE_CREATED', 'SUB_COURSE', 1, 'Created sub-course: Linear Equations', '{"title": "Linear Equations", "course_id": 1, "level": "BEGINNER"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '24 days'),
(1, 'SUPER_ADMIN', 'SUB_COURSE_CREATED', 'SUB_COURSE', 2, 'Created sub-course: Quadratic Equations', '{"title": "Quadratic Equations", "course_id": 1, "level": "INTERMEDIATE"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '23 days'),
(2, 'ADMIN', 'SUB_COURSE_CREATED', 'SUB_COURSE', 3, 'Created sub-course: Cell Biology', '{"title": "Cell Biology", "course_id": 2, "level": "BEGINNER"}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '22 days'),
(1, 'SUPER_ADMIN', 'VIDEO_CREATED', 'VIDEO', 1, 'Created video: Introduction to Algebra', '{"title": "Introduction to Algebra", "sub_course_id": 1}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '21 days'),
(1, 'SUPER_ADMIN', 'VIDEO_UPLOADED', 'VIDEO', 1, 'Uploaded video to Vimeo: Introduction to Algebra', '{"title": "Introduction to Algebra", "vimeo_id": "987654321", "file_size": 52428800}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '21 days'),
(1, 'SUPER_ADMIN', 'VIDEO_PUBLISHED', 'VIDEO', 1, 'Published video: Introduction to Algebra', '{"title": "Introduction to Algebra"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '20 days'),
(2, 'ADMIN', 'VIDEO_CREATED', 'VIDEO', 2, 'Created video: Solving for X', '{"title": "Solving for X", "sub_course_id": 1}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '19 days'),
(2, 'ADMIN', 'VIDEO_UPLOADED', 'VIDEO', 2, 'Uploaded video to Vimeo: Solving for X', '{"title": "Solving for X", "vimeo_id": "987654322", "file_size": 41943040}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '19 days'),
(1, 'SUPER_ADMIN', 'COURSE_UPDATED', 'COURSE', 1, 'Updated course: Algebra Fundamentals', '{"title": "Algebra Fundamentals", "changed_fields": ["description", "thumbnail"]}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '18 days'),
(1, 'SUPER_ADMIN', 'CATEGORY_UPDATED', 'CATEGORY', 1, 'Updated course category: Mathematics & Statistics', '{"name": "Mathematics & Statistics"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '17 days'),
(2, 'ADMIN', 'VIDEO_CREATED', 'VIDEO', 3, 'Created video: Cell Structure Overview', '{"title": "Cell Structure Overview", "sub_course_id": 3}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '15 days'),
(2, 'ADMIN', 'VIDEO_UPLOADED', 'VIDEO', 3, 'Uploaded video to Vimeo: Cell Structure Overview', '{"title": "Cell Structure Overview", "vimeo_id": "987654323", "file_size": 73400320}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '15 days'),
(2, 'ADMIN', 'VIDEO_PUBLISHED', 'VIDEO', 2, 'Published video: Solving for X', '{"title": "Solving for X"}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '14 days'),
(1, 'SUPER_ADMIN', 'SUB_COURSE_UPDATED', 'SUB_COURSE', 2, 'Updated sub-course: Quadratic Equations', '{"title": "Quadratic Equations", "changed_fields": ["description"]}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '12 days'),
(2, 'ADMIN', 'VIDEO_UPDATED', 'VIDEO', 3, 'Updated video: Cell Structure Overview', '{"title": "Cell Structure Overview", "changed_fields": ["thumbnail", "resolution"]}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '10 days'),
(2, 'ADMIN', 'VIDEO_PUBLISHED', 'VIDEO', 3, 'Published video: Cell Structure Overview', '{"title": "Cell Structure Overview"}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '9 days'),
(1, 'SUPER_ADMIN', 'VIDEO_ARCHIVED', 'VIDEO', 4, 'Archived video ID: 4', '{"id": 4}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '7 days'),
(1, 'SUPER_ADMIN', 'SETTINGS_UPDATED', 'SETTINGS', NULL, 'Updated global settings', '{"keys": ["site_name", "maintenance_mode"]}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '5 days'),
(1, 'SUPER_ADMIN', 'TEAM_MEMBER_CREATED', 'TEAM_MEMBER', 3, 'Created team member: John Doe', '{"name": "John Doe", "role": "instructor"}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '4 days'),
(1, 'SUPER_ADMIN', 'COURSE_CREATED', 'COURSE', 4, 'Created course: Advanced Physics', '{"title": "Advanced Physics", "category_id": 2}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '3 days'),
(2, 'ADMIN', 'CATEGORY_DELETED', 'CATEGORY', 5, 'Deleted category ID: 5', '{"id": 5}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '2 days'),
(1, 'SUPER_ADMIN', 'SUB_COURSE_DELETED', 'SUB_COURSE', 6, 'Deleted sub-course ID: 6', '{"id": 6}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '1 day'),
(2, 'ADMIN', 'VIDEO_DELETED', 'VIDEO', 5, 'Deleted video ID: 5', '{"id": 5}', '10.0.0.55', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', now() - interval '6 hours'),
(1, 'SUPER_ADMIN', 'TEAM_MEMBER_UPDATED', 'TEAM_MEMBER', 3, 'Updated team member: John Doe', '{"name": "John Doe", "changed_fields": ["role"]}', '192.168.1.10', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', now() - interval '2 hours');
-- Intentionally empty: no demo activity log seed (login-only seed in 001).

View File

@ -1,14 +1 @@
INSERT INTO reported_issues (user_id, user_role, subject, description, issue_type, status, metadata, created_at, updated_at) VALUES
(10, 'USER', 'Video not loading on mobile', 'When I try to play the Algebra Fundamentals introduction video on my phone, it shows a blank screen with a spinner that never stops.', 'video', 'pending', '{"course": "Algebra Fundamentals", "device": "iPhone 14", "browser": "Safari 17"}', now() - interval '14 days', now() - interval '14 days'),
(10, 'USER', 'Payment confirmation not received', 'I subscribed to the premium plan yesterday and the money was deducted from my account, but I have not received any confirmation email or SMS.', 'payment', 'in_progress', '{"plan": "Premium", "amount": 500, "payment_method": "telebirr"}', now() - interval '10 days', now() - interval '8 days'),
(10, 'USER', 'Cannot change profile picture', 'I am trying to upload a new profile picture but the upload button does not respond when I click it.', 'account', 'resolved', '{"browser": "Chrome 120", "file_type": "jpg", "file_size_kb": 2048}', now() - interval '20 days', now() - interval '15 days'),
(10, 'USER', 'Add dark mode support', 'It would be great if the platform had a dark mode option. Studying at night with the bright white background is hard on the eyes.', 'feature_request', 'pending', '{"platform": "web"}', now() - interval '7 days', now() - interval '7 days'),
(10, 'USER', 'Quiz results not saving', 'I completed the Biology 101 quiz but when I go back to check my results, it shows as incomplete.', 'bug', 'in_progress', '{"course": "Biology 101", "quiz_id": 5, "attempts": 3}', now() - interval '5 days', now() - interval '3 days'),
(12, 'SUPPORT', 'Course content displays incorrectly on tablets', 'Multiple users have reported that course text overlaps with images on tablet devices in landscape mode.', 'content', 'pending', '{"affected_devices": ["iPad Air", "Samsung Galaxy Tab S9"], "orientation": "landscape"}', now() - interval '12 days', now() - interval '12 days'),
(12, 'SUPPORT', 'Login fails after password reset', 'After resetting my password through the forgot password flow, the new password is not accepted for login.', 'login', 'resolved', '{"browser": "Firefox 121", "reset_method": "email"}', now() - interval '25 days', now() - interval '18 days'),
(12, 'SUPPORT', 'Slow page load times', 'The course listing page takes over 10 seconds to load, especially when filtering by category.', 'performance', 'in_progress', '{"page": "/courses", "avg_load_time_ms": 12500, "filter": "category=Science"}', now() - interval '9 days', now() - interval '6 days'),
(10, 'USER', 'Subscription auto-renewal not working', 'My monthly subscription expired even though I had auto-renewal enabled. I had to manually resubscribe.', 'subscription', 'rejected', '{"plan": "Monthly Basic", "expected_renewal": "2026-01-15"}', now() - interval '30 days', now() - interval '22 days'),
(12, 'SUPPORT', 'Screen reader cannot read course navigation', 'The course sidebar navigation is not accessible with screen readers. ARIA labels are missing on several interactive elements.', 'accessibility', 'pending', '{"screen_reader": "NVDA", "browser": "Chrome 120", "affected_elements": ["sidebar nav", "progress bar", "video controls"]}', now() - interval '4 days', now() - interval '4 days'),
(10, 'USER', 'Certificate download gives 404 error', 'After completing the English Grammar course, clicking the download certificate button returns a page not found error.', 'course', 'pending', '{"course": "English Grammar", "completion_date": "2026-01-28"}', now() - interval '2 days', now() - interval '2 days'),
(10, 'USER', 'Cannot access course after subscription renewal', 'I renewed my subscription but I still cannot access premium courses. It says my subscription is inactive.', 'subscription', 'in_progress', '{"plan": "Premium Annual", "renewal_date": "2026-02-01"}', now() - interval '1 day', now() - interval '12 hours')
ON CONFLICT DO NOTHING;
-- Intentionally empty: no demo issue-report seed (login-only seed in 001).

View File

@ -1,40 +1 @@
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'),
-- 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;
-- Intentionally empty: no demo notification seed (login-only seed in 001).

View File

@ -1,469 +1,2 @@
-- ======================================================
-- Complete Course Management Seed Data
-- Covers: categories, courses, sub-courses, videos,
-- question sets, questions, options, prerequisites,
-- and user progress for admin panel integration
-- ======================================================
-- ======================================================
-- Course Categories (supplement existing 3 categories)
-- Existing: 1=Programming, 2=Data Science, 3=Web Development
-- ======================================================
INSERT INTO course_categories (id, name, is_active, created_at) VALUES
(4, 'Mobile Development', TRUE, CURRENT_TIMESTAMP),
(5, 'DevOps & Cloud', TRUE, CURRENT_TIMESTAMP),
(6, 'Cybersecurity', FALSE, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Courses (supplement existing 7 courses)
-- Existing: 1-7 in categories 1-3
-- ======================================================
INSERT INTO courses (id, category_id, title, description, thumbnail, intro_video_url, is_active) VALUES
(8, 4, 'Flutter App Development', 'Build cross-platform mobile apps with Flutter and Dart', 'https://example.com/thumbnails/flutter.jpg', 'https://example.com/intro/flutter.mp4', TRUE),
(9, 4, 'React Native Essentials', 'Create native mobile apps using React Native', 'https://example.com/thumbnails/react-native.jpg', NULL, TRUE),
(10, 5, 'Docker & Kubernetes', 'Container orchestration and deployment strategies', 'https://example.com/thumbnails/docker-k8s.jpg', 'https://example.com/intro/docker.mp4', TRUE),
(11, 5, 'CI/CD Pipeline Mastery', 'Automate your build, test, and deployment workflows', 'https://example.com/thumbnails/cicd.jpg', NULL, FALSE),
(12, 6, 'Ethical Hacking Fundamentals', 'Learn penetration testing and security analysis', 'https://example.com/thumbnails/ethical-hacking.jpg', NULL, FALSE)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Sub-courses (supplement existing 17 sub-courses: IDs 1-17)
-- ======================================================
INSERT INTO sub_courses (id, course_id, title, description, thumbnail, display_order, level, sub_level, is_active) VALUES
-- Flutter sub-courses (course 8) — IDs 18-21
(18, 8, 'Dart Language Basics', 'Learn Dart programming language fundamentals', NULL, 1, 'BEGINNER', 'A1', TRUE),
(19, 8, 'Flutter UI Widgets', 'Build beautiful UIs with Flutter widgets', NULL, 2, 'BEGINNER', 'A2', TRUE),
(20, 8, 'State Management', 'Manage app state with Provider and Riverpod', NULL, 3, 'INTERMEDIATE', 'B1', TRUE),
(21, 8, 'Flutter Networking & APIs', 'HTTP requests, REST APIs, and data persistence', NULL, 4, 'ADVANCED', 'C1', TRUE),
-- React Native sub-courses (course 9) — IDs 22-24
(22, 9, 'React Native Setup', 'Environment setup and first app', NULL, 1, 'BEGINNER', 'A1', TRUE),
(23, 9, 'Navigation & Routing', 'React Navigation and screen management', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
(24, 9, 'Native Modules', 'Bridge native code with React Native', NULL, 3, 'ADVANCED', 'C1', TRUE),
-- Docker & Kubernetes sub-courses (course 10) — IDs 25-27
(25, 10, 'Docker Fundamentals', 'Containers, images, and Dockerfiles', NULL, 1, 'BEGINNER', 'A1', TRUE),
(26, 10, 'Docker Compose', 'Multi-container applications', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
(27, 10, 'Kubernetes Basics', 'Pods, services, and deployments', NULL, 3, 'ADVANCED', 'C1', TRUE),
-- CI/CD sub-courses (course 11) — IDs 28-29
(28, 11, 'Git Workflows', 'Branching strategies and pull requests', NULL, 1, 'BEGINNER', 'A1', TRUE),
(29, 11, 'GitHub Actions', 'Automate workflows with GitHub Actions', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
-- Cybersecurity sub-courses (course 12) — IDs 30-31
(30, 12, 'Network Security Basics', 'Firewalls, VPNs, and network protocols', NULL, 1, 'BEGINNER', 'A1', TRUE),
(31, 12, 'Penetration Testing', 'Tools and techniques for pen testing', NULL, 2, 'ADVANCED', 'C1', TRUE)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Sub-course Videos (supplement existing 5 videos: IDs 1-5)
-- ======================================================
INSERT INTO sub_course_videos (
id, sub_course_id, title, description, video_url,
duration, resolution, visibility, display_order, status,
video_host_provider, vimeo_id, vimeo_embed_url, vimeo_status
) VALUES
-- Dart Language Basics videos (sub_course 18)
(6, 18, 'Introduction to Dart', 'Overview of Dart programming language', 'https://example.com/dart-intro.mp4', 720, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(7, 18, 'Variables and Data Types', 'Dart variables, constants, and types', 'https://example.com/dart-variables.mp4', 900, '1080p', 'public', 2, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(8, 18, 'Control Flow in Dart', 'If/else, loops, and switch statements', 'https://example.com/dart-control.mp4', 1100, '720p', 'public', 3, 'DRAFT', 'DIRECT', NULL, NULL, NULL),
-- Flutter UI Widgets videos (sub_course 19)
(9, 19, 'Widget Tree Basics', 'Understanding the Flutter widget tree', 'https://player.vimeo.com/video/100000001', 1500, '1080p', 'public', 1, 'PUBLISHED', 'VIMEO', '100000001', 'https://player.vimeo.com/video/100000001', 'available'),
(10, 19, 'Layout Widgets', 'Row, Column, Stack, and Container widgets', 'https://player.vimeo.com/video/100000002', 1800, '1080p', 'public', 2, 'PUBLISHED', 'VIMEO', '100000002', 'https://player.vimeo.com/video/100000002', 'available'),
(11, 19, 'Custom Widgets', 'Building reusable custom widgets', 'https://player.vimeo.com/video/100000003', 2100, '1080p', 'public', 3, 'DRAFT', 'VIMEO', '100000003', 'https://player.vimeo.com/video/100000003', 'transcoding'),
-- State Management videos (sub_course 20)
(12, 20, 'setState and Stateful Widgets', 'Managing local state in Flutter', 'https://example.com/flutter-setstate.mp4', 1200, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(13, 20, 'Provider Pattern', 'Global state management with Provider', 'https://example.com/flutter-provider.mp4', 1600, '1080p', 'public', 2, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
-- Docker Fundamentals videos (sub_course 25)
(14, 25, 'What is Docker?', 'Introduction to containerization', 'https://example.com/docker-intro.mp4', 600, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(15, 25, 'Building Docker Images', 'Writing Dockerfiles and building images', 'https://example.com/docker-images.mp4', 1400, '1080p', 'public', 2, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
-- Docker Compose videos (sub_course 26)
(16, 26, 'Docker Compose Basics', 'Defining multi-container applications', 'https://example.com/compose-basics.mp4', 1300, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
-- React Native Setup videos (sub_course 22)
(17, 22, 'Setting Up React Native', 'Installing React Native CLI and Expo', 'https://example.com/rn-setup.mp4', 900, '1080p', 'public', 1, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL),
(18, 22, 'Your First React Native App', 'Creating and running a basic app', 'https://example.com/rn-first-app.mp4', 1100, '1080p', 'public', 2, 'PUBLISHED', 'DIRECT', NULL, NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Question Options for existing practice questions (17-20)
-- These were missing from the initial seed
-- ======================================================
INSERT INTO question_options (question_id, option_text, option_order, is_correct) VALUES
-- Q17: What is the correct way to print "Hello World" in Python?
(17, 'print("Hello World")', 1, TRUE),
(17, 'echo "Hello World"', 2, FALSE),
(17, 'console.log("Hello World")', 3, FALSE),
(17, 'System.out.println("Hello World")', 4, FALSE),
-- Q18: Which is a valid Python variable name?
(18, '2name', 1, FALSE),
(18, 'my_name', 2, TRUE),
(18, 'my-name', 3, FALSE),
(18, 'class', 4, FALSE),
-- Q19: How do you convert "123" to an integer?
(19, 'int("123")', 1, TRUE),
(19, 'integer("123")', 2, FALSE),
(19, 'str(123)', 3, FALSE),
(19, 'toInt("123")', 4, FALSE),
-- Q20: How many times does range(3) loop run?
(20, '2', 1, FALSE),
(20, '3', 2, TRUE),
(20, '4', 3, FALSE),
(20, '1', 4, FALSE);
-- ======================================================
-- Additional Practice Questions for new sub-courses
-- ======================================================
INSERT INTO questions (id, question_text, question_type, tips, status) VALUES
(21, 'What keyword is used to declare a variable in Dart?', 'MCQ', 'Dart uses var, final, or const', 'PUBLISHED'),
(22, 'Which widget is the root of every Flutter app?', 'MCQ', 'Think about the main() function', 'PUBLISHED'),
(23, 'What is a StatefulWidget?', 'MCQ', 'Consider mutable state', 'PUBLISHED'),
(24, 'What command creates a Docker container from an image?', 'MCQ', 'Think about docker run', 'PUBLISHED'),
(25, 'What file defines a Docker Compose application?', 'MCQ', 'It is a YAML file', 'PUBLISHED'),
(26, 'Which tool is used to create a new React Native project?', 'MCQ', 'Consider npx or expo', 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_options (question_id, option_text, option_order, is_correct) VALUES
-- Q21: Dart variable declaration
(21, 'var', 1, TRUE),
(21, 'let', 2, FALSE),
(21, 'dim', 3, FALSE),
(21, 'define', 4, FALSE),
-- Q22: Root Flutter widget
(22, 'MaterialApp', 1, TRUE),
(22, 'Container', 2, FALSE),
(22, 'Scaffold', 3, FALSE),
(22, 'AppBar', 4, FALSE),
-- Q23: StatefulWidget
(23, 'A widget that can change its state during its lifetime', 1, TRUE),
(23, 'A widget that never changes', 2, FALSE),
(23, 'A widget for static content only', 3, FALSE),
(23, 'A widget that cannot have children', 4, FALSE),
-- Q24: Docker container creation
(24, 'docker run', 1, TRUE),
(24, 'docker create', 2, FALSE),
(24, 'docker start', 3, FALSE),
(24, 'docker build', 4, FALSE),
-- Q25: Docker Compose file
(25, 'docker-compose.yml', 1, TRUE),
(25, 'Dockerfile', 2, FALSE),
(25, 'docker.json', 3, FALSE),
(25, 'compose.xml', 4, FALSE),
-- Q26: React Native project creation
(26, 'npx react-native init', 1, TRUE),
(26, 'npm create react-native', 2, FALSE),
(26, 'react-native new', 3, FALSE),
(26, 'rn init', 4, FALSE);
-- ======================================================
-- Question Sets for new sub-courses
-- ======================================================
INSERT INTO question_sets (id, title, description, set_type, owner_type, owner_id, persona, status) VALUES
(5, 'Dart Basics Quiz', 'Test your Dart fundamentals', 'PRACTICE', 'SUB_COURSE', 18, 'beginner', 'PUBLISHED'),
(6, 'Flutter Widgets Assessment', 'Assess Flutter widget knowledge', 'PRACTICE', 'SUB_COURSE', 19, 'beginner', 'PUBLISHED'),
(7, 'State Management Quiz', 'Test state management concepts', 'PRACTICE', 'SUB_COURSE', 20, 'intermediate', 'DRAFT'),
(8, 'Docker Fundamentals Quiz', 'Test Docker basics', 'PRACTICE', 'SUB_COURSE', 25, 'beginner', 'PUBLISHED'),
(9, 'Docker Compose Assessment', 'Assess Docker Compose skills', 'PRACTICE', 'SUB_COURSE', 26, 'intermediate', 'PUBLISHED'),
(10, 'React Native Setup Quiz', 'Test React Native setup knowledge', 'PRACTICE', 'SUB_COURSE', 22, 'beginner', 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
-- Ensure every sub-course has at least one practice set
INSERT INTO question_sets (title, description, set_type, owner_type, owner_id, status)
SELECT
sc.title || ' Practice',
'Default practice set for ' || sc.title,
'PRACTICE',
'SUB_COURSE',
sc.id,
'DRAFT'
FROM sub_courses sc
WHERE NOT EXISTS (
SELECT 1
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.owner_id = sc.id
AND qs.set_type = 'PRACTICE'
AND qs.status != 'ARCHIVED'
);
-- Ensure every sub-course has one initial assessment set
INSERT INTO question_sets (title, description, set_type, owner_type, owner_id, status)
SELECT
sc.title || ' Entry Assessment',
'Initial assessment used before learners start ' || sc.title,
'INITIAL_ASSESSMENT',
'SUB_COURSE',
sc.id,
'DRAFT'
FROM sub_courses sc
WHERE NOT EXISTS (
SELECT 1
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.owner_id = sc.id
AND qs.set_type = 'INITIAL_ASSESSMENT'
AND qs.status != 'ARCHIVED'
);
-- Link questions to question sets
INSERT INTO question_set_items (set_id, question_id, display_order) VALUES
(5, 21, 1),
(6, 22, 1),
(7, 23, 1),
(8, 24, 1),
(9, 25, 1),
(10, 26, 1)
ON CONFLICT (set_id, question_id) DO NOTHING;
-- Link personas to question sets
INSERT INTO question_set_personas (question_set_id, user_id, display_order) VALUES
(5, 10, 1), (5, 11, 2),
(6, 10, 1), (6, 12, 2),
(8, 11, 1),
(10, 10, 1)
ON CONFLICT (question_set_id, user_id) DO NOTHING;
-- ======================================================
-- Sub-course Prerequisites
-- Defines the learning path / dependency graph
-- ======================================================
INSERT INTO sub_course_prerequisites (sub_course_id, prerequisite_sub_course_id) VALUES
-- Python course (IDs 1-5): linear progression
-- "Python Basics - Data Types" requires "Python Basics - Getting Started"
(2, 1),
-- "Python Intermediate - Functions" requires "Python Basics - Data Types"
(3, 2),
-- "Python Intermediate - Collections" requires "Python Intermediate - Functions"
(4, 3),
-- "Python Advanced - Best Practices" requires "Python Intermediate - Collections"
(5, 4),
-- JavaScript course (IDs 6-7): linear
-- "DOM Manipulation Basics" requires "JavaScript Fundamentals"
(7, 6),
-- Java course (IDs 8-9): linear
-- "Spring Framework Intro" requires "Java Core Concepts"
(9, 8),
-- Data Science course (IDs 10-11): linear
-- "Advanced Data Analysis" requires "Data Analysis Fundamentals"
(11, 10),
-- ML course (IDs 12-13): linear
-- "ML Algorithms" requires "ML Basics"
(13, 12),
-- Full Stack course (IDs 14-15): linear
-- "Backend Development" requires "Frontend Fundamentals"
(15, 14),
-- React course (IDs 16-17): linear
-- "React Advanced Patterns" requires "React Basics"
(17, 16),
-- Flutter course (IDs 18-21): structured path
-- "Flutter UI Widgets" requires "Dart Language Basics"
(19, 18),
-- "State Management" requires "Flutter UI Widgets"
(20, 19),
-- "Flutter Networking & APIs" requires "State Management"
(21, 20),
-- React Native course (IDs 22-24): linear
-- "Navigation & Routing" requires "React Native Setup"
(23, 22),
-- "Native Modules" requires "Navigation & Routing"
(24, 23),
-- Docker & Kubernetes course (IDs 25-27): structured
-- "Docker Compose" requires "Docker Fundamentals"
(26, 25),
-- "Kubernetes Basics" requires "Docker Compose"
(27, 26),
-- CI/CD course (IDs 28-29): linear
-- "GitHub Actions" requires "Git Workflows"
(29, 28),
-- Cybersecurity course (IDs 30-31): linear
-- "Penetration Testing" requires "Network Security Basics"
(31, 30)
ON CONFLICT (sub_course_id, prerequisite_sub_course_id) DO NOTHING;
-- ======================================================
-- Completion-driven progress seed (auto-aggregate model)
-- Seed video/practice completion records, then derive sub-course progress
-- ======================================================
-- 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;
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;
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;
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
-- ======================================================
SELECT setval(pg_get_serial_sequence('course_categories', 'id'), COALESCE((SELECT MAX(id) FROM course_categories), 1), true);
SELECT setval(pg_get_serial_sequence('courses', 'id'), COALESCE((SELECT MAX(id) FROM courses), 1), true);
SELECT setval(pg_get_serial_sequence('sub_courses', 'id'), COALESCE((SELECT MAX(id) FROM sub_courses), 1), true);
SELECT setval(pg_get_serial_sequence('sub_course_videos', 'id'), COALESCE((SELECT MAX(id) FROM sub_course_videos), 1), true);
SELECT setval(pg_get_serial_sequence('questions', 'id'), COALESCE((SELECT MAX(id) FROM questions), 1), true);
SELECT setval(pg_get_serial_sequence('question_options', 'id'), COALESCE((SELECT MAX(id) FROM question_options), 1), true);
SELECT setval(pg_get_serial_sequence('question_sets', 'id'), COALESCE((SELECT MAX(id) FROM question_sets), 1), true);
SELECT setval(pg_get_serial_sequence('question_set_items', 'id'), COALESCE((SELECT MAX(id) FROM question_set_items), 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('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);
-- Intentionally empty: course hierarchy is not seeded from SQL.
-- Use admin/API or migrations to create content.

View File

@ -1,29 +1 @@
-- Seed account deletion request states for admin panel tracking
-- Users referenced here are seeded in 001_initial_seed_data.sql (IDs: 10, 11, 12).
-- Pending deletion request (within grace period)
UPDATE users
SET
deletion_requested_at = now() - interval '2 days',
deletion_scheduled_at = now() + interval '13 days',
deletion_cancelled_at = NULL,
updated_at = now()
WHERE id = 10;
-- Due deletion request (grace period elapsed, awaiting purge worker)
UPDATE users
SET
deletion_requested_at = now() - interval '20 days',
deletion_scheduled_at = now() - interval '5 days',
deletion_cancelled_at = NULL,
updated_at = now()
WHERE id = 11;
-- Cancelled deletion request (request made then cancelled)
UPDATE users
SET
deletion_requested_at = now() - interval '10 days',
deletion_scheduled_at = now() + interval '5 days',
deletion_cancelled_at = now() - interval '3 days',
updated_at = now()
WHERE id = 12;
-- Intentionally empty: no demo account-deletion seed (login-only seed in 001).

View File

@ -1,67 +1 @@
-- Seed TRUE_FALSE and SHORT_ANSWER question types
-- Ensures question sets contain non-MCQ questions for end-to-end testing.
-- ======================================================
-- TRUE_FALSE questions (stored in questions + question_options)
-- ======================================================
INSERT INTO questions (
id,
question_text,
question_type,
difficulty_level,
points,
status,
created_at
)
VALUES
(27, 'The Python interpreter executes Python code top-to-bottom.', 'TRUE_FALSE', 'EASY', 1, 'PUBLISHED', CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- question_options for TRUE_FALSE: use two options with exactly one correct
INSERT INTO question_options (question_id, option_text, option_order, is_correct)
VALUES
(27, 'True', 1, TRUE),
(27, 'False', 2, FALSE)
ON CONFLICT DO NOTHING;
-- ======================================================
-- SHORT_ANSWER questions (stored in questions + question_short_answers)
-- ======================================================
INSERT INTO questions (
id,
question_text,
question_type,
difficulty_level,
points,
status,
created_at
)
VALUES
(29, 'What keyword is used in Python to define a function?', 'SHORT_ANSWER', 'EASY', 1, 'PUBLISHED', CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
INSERT INTO question_short_answers (question_id, acceptable_answer, match_type)
VALUES
(29, 'def', 'EXACT')
ON CONFLICT DO NOTHING;
-- ======================================================
-- Link new questions into existing question sets
-- Question Set 1: Initial Assessment (set_id = 1, PUBLISHED)
-- Question Set 2: Python Basics Assessment (set_id = 2, PUBLISHED)
-- ======================================================
INSERT INTO question_set_items (set_id, question_id, display_order)
VALUES
(1, 27, 17),
(1, 29, 18),
(2, 27, 3),
(2, 29, 4)
ON CONFLICT (set_id, question_id) DO NOTHING;
-- ======================================================
-- Reset sequences to avoid ID collisions after seeding
-- ======================================================
SELECT setval(pg_get_serial_sequence('questions', 'id'), COALESCE((SELECT MAX(id) FROM questions), 1), true);
SELECT setval(pg_get_serial_sequence('question_options', 'id'), COALESCE((SELECT MAX(id) FROM question_options), 1), true);
SELECT setval(pg_get_serial_sequence('question_short_answers', 'id'), COALESCE((SELECT MAX(id) FROM question_short_answers), 1), true);
-- Intentionally empty: no demo question seed (login-only seed in 001).

View File

@ -0,0 +1,25 @@
UPDATE question_sets qs
SET owner_type = 'SUB_COURSE',
owner_id = sm.legacy_sub_course_id
FROM sub_modules sm
WHERE qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
AND qs.set_type = 'PRACTICE'
AND sm.legacy_sub_course_id IS NOT NULL;
DROP TABLE IF EXISTS sub_module_practices CASCADE;
DROP TABLE IF EXISTS sub_module_videos CASCADE;
DROP TABLE IF EXISTS sub_modules CASCADE;
DROP TABLE IF EXISTS modules CASCADE;
DROP TABLE IF EXISTS levels CASCADE;
ALTER TABLE courses DROP COLUMN IF EXISTS sub_category_id;
DROP TABLE IF EXISTS course_sub_categories CASCADE;
-- Best-effort rollback to old expectation.
UPDATE user_practice_progress
SET sub_course_id = 1
WHERE sub_course_id IS NULL;
ALTER TABLE user_practice_progress
ALTER COLUMN sub_course_id SET NOT NULL;

View File

@ -0,0 +1,228 @@
-- Unified hierarchy
-- Course Category -> Course Sub-category -> Course -> Level -> Module -> Sub-Module
-- -> Sub-Module Videos
-- -> Sub-Module Practices (question sets)
CREATE TABLE IF NOT EXISTS course_sub_categories (
id BIGSERIAL PRIMARY KEY,
category_id BIGINT NOT NULL REFERENCES course_categories(id) ON DELETE CASCADE,
name VARCHAR(150) NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
display_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(category_id, name)
);
ALTER TABLE courses
ADD COLUMN IF NOT EXISTS sub_category_id BIGINT REFERENCES course_sub_categories(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_courses_sub_category_id ON courses(sub_category_id);
CREATE TABLE IF NOT EXISTS levels (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
cefr_level VARCHAR(2) NOT NULL,
display_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(course_id, cefr_level),
CHECK (cefr_level IN ('A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3'))
);
CREATE INDEX IF NOT EXISTS idx_levels_course_id ON levels(course_id);
CREATE TABLE IF NOT EXISTS modules (
id BIGSERIAL PRIMARY KEY,
level_id BIGINT NOT NULL REFERENCES levels(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
display_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_modules_level_id ON modules(level_id);
CREATE TABLE IF NOT EXISTS sub_modules (
id BIGSERIAL PRIMARY KEY,
module_id BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
display_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
legacy_sub_course_id BIGINT UNIQUE
);
CREATE INDEX IF NOT EXISTS idx_sub_modules_module_id ON sub_modules(module_id);
CREATE TABLE IF NOT EXISTS sub_module_videos (
id BIGSERIAL PRIMARY KEY,
sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
video_url TEXT NOT NULL,
duration INT,
resolution VARCHAR(20),
is_published BOOLEAN NOT NULL DEFAULT FALSE,
publish_date TIMESTAMPTZ,
visibility VARCHAR(50),
instructor_id VARCHAR(100),
thumbnail TEXT,
display_order INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
vimeo_id TEXT,
vimeo_embed_url TEXT,
vimeo_player_html TEXT,
vimeo_status VARCHAR(50),
video_host_provider VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sub_module_videos_sub_module_id ON sub_module_videos(sub_module_id);
CREATE TABLE IF NOT EXISTS sub_module_practices (
id BIGSERIAL PRIMARY KEY,
sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE,
question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE,
intro_video_url TEXT,
display_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(question_set_id)
);
CREATE INDEX IF NOT EXISTS idx_sub_module_practices_sub_module_id ON sub_module_practices(sub_module_id);
-- Practice progress now supports sub-module owned practices where no legacy sub_course exists.
ALTER TABLE user_practice_progress
ALTER COLUMN sub_course_id DROP NOT NULL;
-- Backfill from existing structure
INSERT INTO course_sub_categories (category_id, name, description, display_order, is_active)
SELECT cc.id, c.title || ' Group', 'Auto-generated from existing course structure', 0, TRUE
FROM courses c
JOIN course_categories cc ON cc.id = c.category_id
LEFT JOIN course_sub_categories csc
ON csc.category_id = cc.id AND csc.name = c.title || ' Group'
WHERE csc.id IS NULL;
UPDATE courses c
SET sub_category_id = csc.id
FROM course_sub_categories csc
WHERE csc.category_id = c.category_id
AND csc.name = c.title || ' Group'
AND c.sub_category_id IS NULL;
INSERT INTO levels (course_id, cefr_level, display_order, is_active)
SELECT
sc.course_id,
sc.sub_level,
MIN(sc.display_order),
BOOL_AND(sc.is_active)
FROM sub_courses sc
WHERE sc.sub_level IN ('A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3')
GROUP BY sc.course_id, sc.sub_level
ON CONFLICT (course_id, cefr_level) DO NOTHING;
INSERT INTO modules (level_id, title, description, display_order, is_active)
SELECT
l.id,
l.cefr_level || ' Module 1',
'Auto-generated default module for ' || l.cefr_level,
1,
l.is_active
FROM levels l
LEFT JOIN modules m ON m.level_id = l.id AND m.display_order = 1
WHERE m.id IS NULL;
INSERT INTO sub_modules (module_id, title, description, display_order, is_active, legacy_sub_course_id)
SELECT
m.id,
sc.title,
sc.description,
sc.display_order,
sc.is_active,
sc.id
FROM sub_courses sc
JOIN levels l
ON l.course_id = sc.course_id
AND l.cefr_level = sc.sub_level
JOIN modules m
ON m.level_id = l.id
AND m.display_order = 1
LEFT JOIN sub_modules sm ON sm.legacy_sub_course_id = sc.id
WHERE sm.id IS NULL;
INSERT INTO sub_module_videos (
sub_module_id,
title,
description,
video_url,
duration,
resolution,
is_published,
publish_date,
visibility,
instructor_id,
thumbnail,
display_order,
status,
vimeo_id,
vimeo_embed_url,
vimeo_player_html,
vimeo_status,
video_host_provider
)
SELECT
sm.id,
scv.title,
scv.description,
scv.video_url,
scv.duration,
scv.resolution,
scv.is_published,
scv.publish_date,
scv.visibility,
scv.instructor_id,
scv.thumbnail,
scv.display_order,
scv.status,
scv.vimeo_id,
scv.vimeo_embed_url,
scv.vimeo_player_html,
scv.vimeo_status,
scv.video_host_provider
FROM sub_course_videos scv
JOIN sub_modules sm ON sm.legacy_sub_course_id = scv.sub_course_id
WHERE NOT EXISTS (
SELECT 1
FROM sub_module_videos smv
WHERE smv.sub_module_id = sm.id
AND smv.title = scv.title
AND COALESCE(smv.video_url, '') = COALESCE(scv.video_url, '')
);
UPDATE question_sets qs
SET owner_type = 'SUB_MODULE',
owner_id = sm.id
FROM sub_modules sm
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.owner_id = sm.legacy_sub_course_id
AND qs.set_type = 'PRACTICE';
INSERT INTO sub_module_practices (sub_module_id, question_set_id, intro_video_url, display_order, is_active)
SELECT
sm.id,
qs.id,
qs.intro_video_url,
COALESCE(qs.display_order, 0),
(qs.status != 'ARCHIVED')
FROM question_sets qs
JOIN sub_modules sm
ON qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
WHERE qs.set_type = 'PRACTICE'
ON CONFLICT (question_set_id) DO NOTHING;

View File

@ -0,0 +1,6 @@
ALTER TABLE IF EXISTS sub_module_lessons
RENAME TO sub_module_practices;
ALTER INDEX IF EXISTS idx_sub_module_lessons_sub_module_id
RENAME TO idx_sub_module_practices_sub_module_id;

View File

@ -0,0 +1,6 @@
ALTER TABLE IF EXISTS sub_module_practices
RENAME TO sub_module_lessons;
ALTER INDEX IF EXISTS idx_sub_module_practices_sub_module_id
RENAME TO idx_sub_module_lessons_sub_module_id;

View File

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_sub_module_practices_sub_module_id;
DROP TABLE IF EXISTS sub_module_practices;

View File

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS sub_module_practices (
id BIGSERIAL PRIMARY KEY,
sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
thumbnail TEXT,
intro_video_url TEXT,
question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE,
display_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(question_set_id)
);
CREATE INDEX IF NOT EXISTS idx_sub_module_practices_sub_module_id
ON sub_module_practices(sub_module_id);

195
db/query/hierarchy.sql Normal file
View File

@ -0,0 +1,195 @@
-- name: GetCoursesWithHierarchy :many
SELECT
cc.id AS category_id,
cc.name AS category_name,
csc.id AS sub_category_id,
csc.name AS sub_category_name,
c.id AS course_id,
c.title AS course_title
FROM course_categories cc
LEFT JOIN course_sub_categories csc ON csc.category_id = cc.id AND csc.is_active = TRUE
LEFT JOIN courses c ON c.sub_category_id = csc.id AND c.is_active = TRUE
WHERE cc.is_active = TRUE
ORDER BY cc.id, csc.display_order, csc.id, c.id;
-- name: GetLevelsByCourseID :many
SELECT *
FROM levels
WHERE course_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetModulesByLevelID :many
SELECT *
FROM modules
WHERE level_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetSubModulesByModuleID :many
SELECT *
FROM sub_modules
WHERE module_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: GetSubModuleVideos :many
SELECT *
FROM sub_module_videos
WHERE sub_module_id = $1
AND status != 'ARCHIVED'
ORDER BY display_order ASC, id ASC;
-- name: GetSubModuleLessons :many
SELECT
smp.id,
smp.sub_module_id,
smp.question_set_id,
smp.intro_video_url,
smp.display_order,
smp.is_active,
qs.title,
qs.description,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_lessons smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.sub_module_id = $1
AND smp.is_active = TRUE
ORDER BY smp.display_order ASC, smp.id ASC;
-- name: GetSubModulePractices :many
SELECT
smp.id,
smp.sub_module_id,
smp.title,
smp.description,
smp.thumbnail,
smp.intro_video_url,
smp.question_set_id,
smp.display_order,
smp.is_active,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_practices smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.sub_module_id = $1
AND smp.is_active = TRUE
ORDER BY smp.display_order ASC, smp.id ASC;
-- name: GetFullHierarchyByCourseID :many
SELECT
c.id AS course_id,
c.title AS course_title,
l.id AS level_id,
l.cefr_level,
m.id AS module_id,
m.title AS module_title,
sm.id AS sub_module_id,
sm.title AS sub_module_title
FROM courses c
LEFT JOIN levels l ON l.course_id = c.id AND l.is_active = TRUE
LEFT JOIN modules m ON m.level_id = l.id AND m.is_active = TRUE
LEFT JOIN sub_modules sm ON sm.module_id = m.id AND sm.is_active = TRUE
WHERE c.id = $1
ORDER BY l.display_order, l.id, m.display_order, m.id, sm.display_order, sm.id;
-- name: CreateCourseSubCategory :one
INSERT INTO course_sub_categories (
category_id,
name,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: CreateLevel :one
INSERT INTO levels (
course_id,
cefr_level,
display_order,
is_active
)
VALUES ($1, $2, COALESCE($3, 0), COALESCE($4, TRUE))
RETURNING *;
-- name: CreateModule :one
INSERT INTO modules (
level_id,
title,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: CreateSubModule :one
INSERT INTO sub_modules (
module_id,
title,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: CreateSubModuleVideo :one
INSERT INTO sub_module_videos (
sub_module_id,
title,
description,
video_url,
duration,
resolution,
is_published,
publish_date,
visibility,
instructor_id,
thumbnail,
display_order,
status,
vimeo_id,
vimeo_embed_url,
vimeo_player_html,
vimeo_status,
video_host_provider
)
VALUES (
$1, $2, $3, $4, $5, $6,
COALESCE($7, FALSE), $8, $9, $10, $11,
COALESCE($12, 0), COALESCE($13, 'DRAFT'),
$14, $15, $16, $17, COALESCE($18, 'DIRECT')
)
RETURNING *;
-- name: AttachQuestionSetLessonToSubModule :one
INSERT INTO sub_module_lessons (
sub_module_id,
question_set_id,
intro_video_url,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING *;
-- name: CreateSubModulePractice :one
INSERT INTO sub_module_practices (
sub_module_id,
title,
description,
thumbnail,
intro_video_url,
question_set_id,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE))
RETURNING *;

View File

@ -1,59 +0,0 @@
-- name: GetFullLearningTree :many
SELECT
c.id AS course_id,
c.title AS course_title,
sc.id AS sub_course_id,
sc.title AS sub_course_title,
sc.level AS sub_course_level,
sc.sub_level AS sub_course_sub_level
FROM courses c
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.is_active = true
ORDER BY c.id, sc.display_order, sc.id;
-- name: GetCourseLearningPath :many
SELECT
c.id AS course_id,
c.title AS course_title,
c.description AS course_description,
c.thumbnail AS course_thumbnail,
c.intro_video_url AS course_intro_video_url,
cc.id AS category_id,
cc.name AS category_name,
sc.id AS sub_course_id,
sc.title AS sub_course_title,
sc.description AS sub_course_description,
sc.thumbnail AS sub_course_thumbnail,
sc.display_order AS sub_course_display_order,
sc.level AS sub_course_level,
sc.sub_level AS sub_course_sub_level,
(SELECT COUNT(*) FROM sub_course_prerequisites WHERE sub_course_id = sc.id) AS prerequisite_count,
(SELECT COUNT(*) FROM sub_course_videos WHERE sub_course_id = sc.id AND status = 'PUBLISHED') AS video_count,
(SELECT COUNT(*) FROM question_sets WHERE set_type = 'PRACTICE' AND owner_type = 'SUB_COURSE' AND owner_id = sc.id AND status = 'PUBLISHED') AS practice_count
FROM courses c
JOIN course_categories cc ON cc.id = c.category_id
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.id = $1
ORDER BY sc.display_order, sc.id;
-- name: GetSubCourseVideosForLearningPath :many
SELECT id, title, description, video_url, duration, resolution, display_order,
vimeo_id, vimeo_embed_url, video_host_provider
FROM sub_course_videos
WHERE sub_course_id = $1 AND status = 'PUBLISHED'
ORDER BY display_order, id;
-- name: GetSubCoursePracticesForLearningPath :many
SELECT id, title, description, persona, status, intro_video_url,
(SELECT COUNT(*) FROM question_set_items WHERE set_id = qs.id) AS question_count
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1
AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED'
ORDER BY qs.display_order ASC, qs.created_at;
-- name: GetSubCoursePrerequisitesForLearningPath :many
SELECT p.prerequisite_sub_course_id, sc.title, sc.level, sc.sub_level
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
WHERE p.sub_course_id = $1
ORDER BY sc.display_order;

View File

@ -0,0 +1,55 @@
-- name: GetFirstIncompletePreviousPractice :one
WITH target AS (
SELECT id, owner_type, owner_id, COALESCE(display_order, 0) AS display_order
FROM question_sets
WHERE id = @question_set_id::BIGINT
AND set_type = 'PRACTICE'
AND status = 'PUBLISHED'
),
candidates AS (
SELECT qs.id, qs.title, COALESCE(qs.display_order, 0) AS display_order
FROM question_sets qs
JOIN target t
ON qs.owner_type = t.owner_type
AND qs.owner_id = t.owner_id
WHERE qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND COALESCE(qs.display_order, 0) < t.display_order
)
SELECT c.id, c.title, c.display_order
FROM candidates c
LEFT JOIN user_practice_progress upp
ON upp.question_set_id = c.id
AND upp.user_id = @user_id::BIGINT
AND upp.completed_at IS NOT NULL
WHERE upp.id IS NULL
ORDER BY c.display_order ASC, c.id ASC
LIMIT 1;
-- name: MarkPracticeCompleted :execrows
INSERT INTO user_practice_progress (
user_id,
sub_course_id,
question_set_id,
completed_at,
updated_at
)
SELECT
@user_id::BIGINT,
CASE
WHEN qs.owner_type = 'SUB_COURSE' THEN qs.owner_id
WHEN qs.owner_type = 'SUB_MODULE' THEN sm.legacy_sub_course_id
ELSE NULL
END,
qs.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM question_sets qs
LEFT JOIN sub_modules sm
ON qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
WHERE qs.id = @question_set_id::BIGINT
ON CONFLICT (user_id, question_set_id) DO UPDATE
SET completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at;

View File

@ -1,50 +0,0 @@
-- name: AddSubCoursePrerequisite :one
INSERT INTO sub_course_prerequisites (sub_course_id, prerequisite_sub_course_id)
VALUES ($1, $2)
RETURNING *;
-- name: RemoveSubCoursePrerequisite :exec
DELETE FROM sub_course_prerequisites
WHERE sub_course_id = $1 AND prerequisite_sub_course_id = $2;
-- name: GetSubCoursePrerequisites :many
SELECT
p.id,
p.sub_course_id,
p.prerequisite_sub_course_id,
p.created_at,
sc.title AS prerequisite_title,
sc.level AS prerequisite_level,
sc.display_order AS prerequisite_display_order
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
WHERE p.sub_course_id = $1
ORDER BY sc.display_order;
-- name: GetSubCourseDependents :many
SELECT
p.id,
p.sub_course_id,
p.prerequisite_sub_course_id,
p.created_at,
sc.title AS dependent_title,
sc.level AS dependent_level
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.sub_course_id
WHERE p.prerequisite_sub_course_id = $1
ORDER BY sc.display_order;
-- name: CountUnmetPrerequisites :one
SELECT COUNT(*)::bigint AS unmet_count
FROM sub_course_prerequisites p
WHERE p.sub_course_id = $1
AND p.prerequisite_sub_course_id NOT IN (
SELECT usp.sub_course_id
FROM user_sub_course_progress usp
WHERE usp.user_id = $2
AND usp.status = 'COMPLETED'
);
-- name: DeleteAllPrerequisitesForSubCourse :exec
DELETE FROM sub_course_prerequisites
WHERE sub_course_id = $1;

View File

@ -1,122 +0,0 @@
-- name: CreateSubCourseVideo :one
INSERT INTO sub_course_videos (
sub_course_id,
title,
description,
video_url,
duration,
resolution,
instructor_id,
thumbnail,
visibility,
display_order,
status,
vimeo_id,
vimeo_embed_url,
vimeo_player_html,
vimeo_status,
video_host_provider
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
COALESCE($10, 0),
COALESCE($11, 'DRAFT'),
$12, $13, $14,
COALESCE($15, 'pending'),
COALESCE($16, 'DIRECT')
)
RETURNING *;
-- name: GetSubCourseVideoByID :one
SELECT *
FROM sub_course_videos
WHERE id = $1;
-- name: GetVideosBySubCourse :many
SELECT
COUNT(*) OVER () AS total_count,
id,
sub_course_id,
title,
description,
video_url,
duration,
resolution,
is_published,
publish_date,
visibility,
instructor_id,
thumbnail,
display_order,
status,
vimeo_id,
vimeo_embed_url,
vimeo_player_html,
vimeo_status,
video_host_provider
FROM sub_course_videos
WHERE sub_course_id = $1
AND status != 'ARCHIVED'
ORDER BY display_order ASC, id ASC;
-- name: GetPublishedVideosBySubCourse :many
SELECT *
FROM sub_course_videos
WHERE sub_course_id = $1
AND status = 'PUBLISHED'
ORDER BY display_order ASC, publish_date ASC;
-- name: PublishSubCourseVideo :exec
UPDATE sub_course_videos
SET
is_published = true,
publish_date = CURRENT_TIMESTAMP,
status = 'PUBLISHED'
WHERE id = $1;
-- name: UpdateSubCourseVideo :exec
UPDATE sub_course_videos
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
video_url = COALESCE($3, video_url),
duration = COALESCE($4, duration),
resolution = COALESCE($5, resolution),
visibility = COALESCE($6, visibility),
thumbnail = COALESCE($7, thumbnail),
display_order = COALESCE($8, display_order),
status = COALESCE($9, status),
vimeo_id = COALESCE($10, vimeo_id),
vimeo_embed_url = COALESCE($11, vimeo_embed_url),
vimeo_player_html = COALESCE($12, vimeo_player_html),
vimeo_status = COALESCE($13, vimeo_status),
video_host_provider = COALESCE($14, video_host_provider)
WHERE id = $15;
-- name: UpdateVimeoStatus :exec
UPDATE sub_course_videos
SET
vimeo_status = $1
WHERE id = $2;
-- name: GetVideosByVimeoID :one
SELECT * FROM sub_course_videos
WHERE vimeo_id = $1;
-- name: ArchiveSubCourseVideo :exec
UPDATE sub_course_videos
SET status = 'ARCHIVED'
WHERE id = $1;
-- name: DeleteSubCourseVideo :exec
DELETE FROM sub_course_videos
WHERE id = $1;
-- name: ReorderSubCourseVideos :exec
UPDATE sub_course_videos
SET display_order = bulk.position
FROM (
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
) AS bulk
WHERE sub_course_videos.id = bulk.id;

View File

@ -1,95 +0,0 @@
-- name: CreateSubCourse :one
INSERT INTO sub_courses (
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
)
VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, $7, COALESCE($8, true))
RETURNING *;
-- name: GetSubCourseByID :one
SELECT *
FROM sub_courses
WHERE id = $1;
-- name: GetSubCoursesByCourse :many
SELECT
COUNT(*) OVER () AS total_count,
id,
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE course_id = $1
ORDER BY display_order ASC, id ASC;
-- name: ListSubCoursesByCourse :many
SELECT
id,
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE course_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC;
-- name: ListActiveSubCourses :many
SELECT
id,
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE is_active = TRUE
ORDER BY display_order ASC;
-- name: UpdateSubCourse :exec
UPDATE sub_courses
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
thumbnail = COALESCE($3, thumbnail),
display_order = COALESCE($4, display_order),
level = COALESCE($5, level),
sub_level = COALESCE($6, sub_level),
is_active = COALESCE($7, is_active)
WHERE id = $8;
-- name: DeleteSubCourse :one
DELETE FROM sub_courses
WHERE id = $1
RETURNING *;
-- name: DeactivateSubCourse :exec
UPDATE sub_courses
SET is_active = FALSE
WHERE id = $1;
-- name: ReorderSubCourses :exec
UPDATE sub_courses
SET display_order = bulk.position
FROM (
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
) AS bulk
WHERE sub_courses.id = bulk.id;

View File

@ -1,51 +0,0 @@
-- name: MarkPracticeCompleted :one
INSERT INTO user_practice_progress (
user_id,
sub_course_id,
question_set_id,
completed_at,
updated_at
)
SELECT
@user_id::BIGINT,
qs.owner_id::BIGINT,
qs.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM question_sets qs
WHERE qs.id = @question_set_id::BIGINT
AND qs.set_type = 'PRACTICE'
AND qs.owner_type = 'SUB_COURSE'
AND qs.status = 'PUBLISHED'
ON CONFLICT (user_id, question_set_id)
DO UPDATE SET
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
-- name: GetFirstIncompletePreviousPractice :one
SELECT
p.id,
p.title,
p.display_order
FROM question_sets target
JOIN question_sets p
ON p.owner_type = 'SUB_COURSE'
AND p.owner_id = target.owner_id
AND p.set_type = 'PRACTICE'
AND p.status = 'PUBLISHED'
AND (
p.display_order < target.display_order OR
(p.display_order = target.display_order AND p.id < target.id)
)
LEFT JOIN user_practice_progress upp
ON upp.question_set_id = p.id
AND upp.user_id = @user_id::BIGINT
AND upp.completed_at IS NOT NULL
WHERE target.id = @question_set_id::BIGINT
AND target.set_type = 'PRACTICE'
AND target.owner_type = 'SUB_COURSE'
AND target.status = 'PUBLISHED'
AND upp.question_set_id IS NULL
ORDER BY p.display_order ASC, p.id ASC
LIMIT 1;

View File

@ -1,78 +0,0 @@
-- name: StartSubCourseProgress :one
INSERT INTO user_sub_course_progress (user_id, sub_course_id)
VALUES ($1, $2)
ON CONFLICT (user_id, sub_course_id) DO NOTHING
RETURNING *;
-- name: UpdateSubCourseProgress :exec
UPDATE user_sub_course_progress
SET
progress_percentage = $1,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = $2 AND sub_course_id = $3;
-- name: CompleteSubCourse :exec
UPDATE user_sub_course_progress
SET
status = 'COMPLETED',
progress_percentage = 100,
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = $1 AND sub_course_id = $2;
-- name: GetUserSubCourseProgress :one
SELECT * FROM user_sub_course_progress
WHERE user_id = $1 AND sub_course_id = $2;
-- name: GetUserCourseProgress :many
SELECT
usp.id,
usp.user_id,
usp.sub_course_id,
usp.status,
usp.progress_percentage,
usp.started_at,
usp.completed_at,
usp.created_at,
usp.updated_at,
sc.title AS sub_course_title,
sc.level AS sub_course_level,
sc.display_order AS sub_course_display_order
FROM user_sub_course_progress usp
JOIN sub_courses sc ON sc.id = usp.sub_course_id
WHERE usp.user_id = $1 AND sc.course_id = $2
ORDER BY sc.display_order;
-- name: GetSubCoursesWithProgressByCourse :many
SELECT
sc.id AS sub_course_id,
sc.title,
sc.description,
sc.thumbnail,
sc.display_order,
sc.level,
sc.is_active,
COALESCE(usp.status, 'NOT_STARTED') AS progress_status,
COALESCE(usp.progress_percentage, 0)::smallint AS progress_percentage,
usp.started_at,
usp.completed_at,
(SELECT COUNT(*)::bigint
FROM sub_course_prerequisites p
WHERE p.sub_course_id = sc.id
AND p.prerequisite_sub_course_id NOT IN (
SELECT usp2.sub_course_id
FROM user_sub_course_progress usp2
WHERE usp2.user_id = $1
AND usp2.status = 'COMPLETED'
)
) AS unmet_prerequisites_count
FROM sub_courses sc
LEFT JOIN user_sub_course_progress usp
ON usp.sub_course_id = sc.id AND usp.user_id = $1
WHERE sc.course_id = $2
AND sc.is_active = true
ORDER BY sc.display_order;
-- name: DeleteUserSubCourseProgress :exec
DELETE FROM user_sub_course_progress
WHERE user_id = $1 AND sub_course_id = $2;

View File

@ -1,44 +0,0 @@
-- name: MarkVideoCompleted :one
INSERT INTO user_sub_course_video_progress (
user_id,
sub_course_id,
video_id,
completed_at,
updated_at
)
SELECT
@user_id::BIGINT,
v.sub_course_id,
v.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM sub_course_videos v
WHERE v.id = @video_id::BIGINT
AND v.status = 'PUBLISHED'
ON CONFLICT (user_id, video_id)
DO UPDATE SET
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
-- name: GetFirstIncompletePreviousVideo :one
SELECT
v.id,
v.title,
v.display_order
FROM sub_course_videos target
JOIN sub_course_videos v
ON v.sub_course_id = target.sub_course_id
AND v.status = 'PUBLISHED'
AND (
v.display_order < target.display_order OR
(v.display_order = target.display_order AND v.id < target.id)
)
LEFT JOIN user_sub_course_video_progress p
ON p.video_id = v.id
AND p.user_id = @user_id::BIGINT
AND p.completed_at IS NOT NULL
WHERE target.id = @video_id::BIGINT
AND p.video_id IS NULL
ORDER BY v.display_order ASC, v.id ASC
LIMIT 1;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,7 @@ INSERT INTO courses (
is_active
)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order
RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id
`
type CreateCourseParams struct {
@ -52,6 +52,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou
&i.Thumbnail,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.SubCategoryID,
)
return i, err
}
@ -67,7 +68,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
}
const GetCourseByID = `-- name: GetCourseByID :one
SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order
SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id
FROM courses
WHERE id = $1
`
@ -84,6 +85,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
&i.Thumbnail,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.SubCategoryID,
)
return i, err
}

766
gen/db/hierarchy.sql.go Normal file
View File

@ -0,0 +1,766 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: hierarchy.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AttachQuestionSetLessonToSubModule = `-- name: AttachQuestionSetLessonToSubModule :one
INSERT INTO sub_module_lessons (
sub_module_id,
question_set_id,
intro_video_url,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING id, sub_module_id, question_set_id, intro_video_url, display_order, is_active, created_at
`
type AttachQuestionSetLessonToSubModuleParams struct {
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) AttachQuestionSetLessonToSubModule(ctx context.Context, arg AttachQuestionSetLessonToSubModuleParams) (SubModuleLesson, error) {
row := q.db.QueryRow(ctx, AttachQuestionSetLessonToSubModule,
arg.SubModuleID,
arg.QuestionSetID,
arg.IntroVideoUrl,
arg.Column4,
arg.Column5,
)
var i SubModuleLesson
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.QuestionSetID,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateCourseSubCategory = `-- name: CreateCourseSubCategory :one
INSERT INTO course_sub_categories (
category_id,
name,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING id, category_id, name, description, is_active, display_order, created_at
`
type CreateCourseSubCategoryParams struct {
CategoryID int64 `json:"category_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) CreateCourseSubCategory(ctx context.Context, arg CreateCourseSubCategoryParams) (CourseSubCategory, error) {
row := q.db.QueryRow(ctx, CreateCourseSubCategory,
arg.CategoryID,
arg.Name,
arg.Description,
arg.Column4,
arg.Column5,
)
var i CourseSubCategory
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Name,
&i.Description,
&i.IsActive,
&i.DisplayOrder,
&i.CreatedAt,
)
return i, err
}
const CreateLevel = `-- name: CreateLevel :one
INSERT INTO levels (
course_id,
cefr_level,
display_order,
is_active
)
VALUES ($1, $2, COALESCE($3, 0), COALESCE($4, TRUE))
RETURNING id, course_id, cefr_level, display_order, is_active, created_at
`
type CreateLevelParams struct {
CourseID int64 `json:"course_id"`
CefrLevel string `json:"cefr_level"`
Column3 interface{} `json:"column_3"`
Column4 interface{} `json:"column_4"`
}
func (q *Queries) CreateLevel(ctx context.Context, arg CreateLevelParams) (Level, error) {
row := q.db.QueryRow(ctx, CreateLevel,
arg.CourseID,
arg.CefrLevel,
arg.Column3,
arg.Column4,
)
var i Level
err := row.Scan(
&i.ID,
&i.CourseID,
&i.CefrLevel,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateModule = `-- name: CreateModule :one
INSERT INTO modules (
level_id,
title,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING id, level_id, title, description, display_order, is_active, created_at
`
type CreateModuleParams struct {
LevelID int64 `json:"level_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) {
row := q.db.QueryRow(ctx, CreateModule,
arg.LevelID,
arg.Title,
arg.Description,
arg.Column4,
arg.Column5,
)
var i Module
err := row.Scan(
&i.ID,
&i.LevelID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateSubModule = `-- name: CreateSubModule :one
INSERT INTO sub_modules (
module_id,
title,
description,
display_order,
is_active
)
VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE))
RETURNING id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id
`
type CreateSubModuleParams struct {
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Column4 interface{} `json:"column_4"`
Column5 interface{} `json:"column_5"`
}
func (q *Queries) CreateSubModule(ctx context.Context, arg CreateSubModuleParams) (SubModule, error) {
row := q.db.QueryRow(ctx, CreateSubModule,
arg.ModuleID,
arg.Title,
arg.Description,
arg.Column4,
arg.Column5,
)
var i SubModule
err := row.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.LegacySubCourseID,
)
return i, err
}
const CreateSubModulePractice = `-- name: CreateSubModulePractice :one
INSERT INTO sub_module_practices (
sub_module_id,
title,
description,
thumbnail,
intro_video_url,
question_set_id,
display_order,
is_active
)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE))
RETURNING id, sub_module_id, title, description, thumbnail, intro_video_url, question_set_id, display_order, is_active, created_at
`
type CreateSubModulePracticeParams struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
Column7 interface{} `json:"column_7"`
Column8 interface{} `json:"column_8"`
}
func (q *Queries) CreateSubModulePractice(ctx context.Context, arg CreateSubModulePracticeParams) (SubModulePractice, error) {
row := q.db.QueryRow(ctx, CreateSubModulePractice,
arg.SubModuleID,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.IntroVideoUrl,
arg.QuestionSetID,
arg.Column7,
arg.Column8,
)
var i SubModulePractice
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.QuestionSetID,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
)
return i, err
}
const CreateSubModuleVideo = `-- name: CreateSubModuleVideo :one
INSERT INTO sub_module_videos (
sub_module_id,
title,
description,
video_url,
duration,
resolution,
is_published,
publish_date,
visibility,
instructor_id,
thumbnail,
display_order,
status,
vimeo_id,
vimeo_embed_url,
vimeo_player_html,
vimeo_status,
video_host_provider
)
VALUES (
$1, $2, $3, $4, $5, $6,
COALESCE($7, FALSE), $8, $9, $10, $11,
COALESCE($12, 0), COALESCE($13, 'DRAFT'),
$14, $15, $16, $17, COALESCE($18, 'DIRECT')
)
RETURNING id, sub_module_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider, created_at
`
type CreateSubModuleVideoParams struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration pgtype.Int4 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
Column7 interface{} `json:"column_7"`
PublishDate pgtype.Timestamptz `json:"publish_date"`
Visibility pgtype.Text `json:"visibility"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
Column12 interface{} `json:"column_12"`
Column13 interface{} `json:"column_13"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
VimeoStatus pgtype.Text `json:"vimeo_status"`
Column18 interface{} `json:"column_18"`
}
func (q *Queries) CreateSubModuleVideo(ctx context.Context, arg CreateSubModuleVideoParams) (SubModuleVideo, error) {
row := q.db.QueryRow(ctx, CreateSubModuleVideo,
arg.SubModuleID,
arg.Title,
arg.Description,
arg.VideoUrl,
arg.Duration,
arg.Resolution,
arg.Column7,
arg.PublishDate,
arg.Visibility,
arg.InstructorID,
arg.Thumbnail,
arg.Column12,
arg.Column13,
arg.VimeoID,
arg.VimeoEmbedUrl,
arg.VimeoPlayerHtml,
arg.VimeoStatus,
arg.Column18,
)
var i SubModuleVideo
err := row.Scan(
&i.ID,
&i.SubModuleID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
&i.CreatedAt,
)
return i, err
}
const GetCoursesWithHierarchy = `-- name: GetCoursesWithHierarchy :many
SELECT
cc.id AS category_id,
cc.name AS category_name,
csc.id AS sub_category_id,
csc.name AS sub_category_name,
c.id AS course_id,
c.title AS course_title
FROM course_categories cc
LEFT JOIN course_sub_categories csc ON csc.category_id = cc.id AND csc.is_active = TRUE
LEFT JOIN courses c ON c.sub_category_id = csc.id AND c.is_active = TRUE
WHERE cc.is_active = TRUE
ORDER BY cc.id, csc.display_order, csc.id, c.id
`
type GetCoursesWithHierarchyRow struct {
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
SubCategoryName pgtype.Text `json:"sub_category_name"`
CourseID pgtype.Int8 `json:"course_id"`
CourseTitle pgtype.Text `json:"course_title"`
}
func (q *Queries) GetCoursesWithHierarchy(ctx context.Context) ([]GetCoursesWithHierarchyRow, error) {
rows, err := q.db.Query(ctx, GetCoursesWithHierarchy)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCoursesWithHierarchyRow
for rows.Next() {
var i GetCoursesWithHierarchyRow
if err := rows.Scan(
&i.CategoryID,
&i.CategoryName,
&i.SubCategoryID,
&i.SubCategoryName,
&i.CourseID,
&i.CourseTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetFullHierarchyByCourseID = `-- name: GetFullHierarchyByCourseID :many
SELECT
c.id AS course_id,
c.title AS course_title,
l.id AS level_id,
l.cefr_level,
m.id AS module_id,
m.title AS module_title,
sm.id AS sub_module_id,
sm.title AS sub_module_title
FROM courses c
LEFT JOIN levels l ON l.course_id = c.id AND l.is_active = TRUE
LEFT JOIN modules m ON m.level_id = l.id AND m.is_active = TRUE
LEFT JOIN sub_modules sm ON sm.module_id = m.id AND sm.is_active = TRUE
WHERE c.id = $1
ORDER BY l.display_order, l.id, m.display_order, m.id, sm.display_order, sm.id
`
type GetFullHierarchyByCourseIDRow struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
LevelID pgtype.Int8 `json:"level_id"`
CefrLevel pgtype.Text `json:"cefr_level"`
ModuleID pgtype.Int8 `json:"module_id"`
ModuleTitle pgtype.Text `json:"module_title"`
SubModuleID pgtype.Int8 `json:"sub_module_id"`
SubModuleTitle pgtype.Text `json:"sub_module_title"`
}
func (q *Queries) GetFullHierarchyByCourseID(ctx context.Context, id int64) ([]GetFullHierarchyByCourseIDRow, error) {
rows, err := q.db.Query(ctx, GetFullHierarchyByCourseID, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetFullHierarchyByCourseIDRow
for rows.Next() {
var i GetFullHierarchyByCourseIDRow
if err := rows.Scan(
&i.CourseID,
&i.CourseTitle,
&i.LevelID,
&i.CefrLevel,
&i.ModuleID,
&i.ModuleTitle,
&i.SubModuleID,
&i.SubModuleTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetLevelsByCourseID = `-- name: GetLevelsByCourseID :many
SELECT id, course_id, cefr_level, display_order, is_active, created_at
FROM levels
WHERE course_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC
`
func (q *Queries) GetLevelsByCourseID(ctx context.Context, courseID int64) ([]Level, error) {
rows, err := q.db.Query(ctx, GetLevelsByCourseID, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Level
for rows.Next() {
var i Level
if err := rows.Scan(
&i.ID,
&i.CourseID,
&i.CefrLevel,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetModulesByLevelID = `-- name: GetModulesByLevelID :many
SELECT id, level_id, title, description, display_order, is_active, created_at
FROM modules
WHERE level_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC
`
func (q *Queries) GetModulesByLevelID(ctx context.Context, levelID int64) ([]Module, error) {
rows, err := q.db.Query(ctx, GetModulesByLevelID, levelID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Module
for rows.Next() {
var i Module
if err := rows.Scan(
&i.ID,
&i.LevelID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubModuleLessons = `-- name: GetSubModuleLessons :many
SELECT
smp.id,
smp.sub_module_id,
smp.question_set_id,
smp.intro_video_url,
smp.display_order,
smp.is_active,
qs.title,
qs.description,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_lessons smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.sub_module_id = $1
AND smp.is_active = TRUE
ORDER BY smp.display_order ASC, smp.id ASC
`
type GetSubModuleLessonsRow struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
SetType string `json:"set_type"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubModuleLessons(ctx context.Context, subModuleID int64) ([]GetSubModuleLessonsRow, error) {
rows, err := q.db.Query(ctx, GetSubModuleLessons, subModuleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubModuleLessonsRow
for rows.Next() {
var i GetSubModuleLessonsRow
if err := rows.Scan(
&i.ID,
&i.SubModuleID,
&i.QuestionSetID,
&i.IntroVideoUrl,
&i.DisplayOrder,
&i.IsActive,
&i.Title,
&i.Description,
&i.Status,
&i.SetType,
&i.QuestionCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubModulePractices = `-- name: GetSubModulePractices :many
SELECT
smp.id,
smp.sub_module_id,
smp.title,
smp.description,
smp.thumbnail,
smp.intro_video_url,
smp.question_set_id,
smp.display_order,
smp.is_active,
qs.status,
qs.set_type,
(SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count
FROM sub_module_practices smp
JOIN question_sets qs ON qs.id = smp.question_set_id
WHERE smp.sub_module_id = $1
AND smp.is_active = TRUE
ORDER BY smp.display_order ASC, smp.id ASC
`
type GetSubModulePracticesRow struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
Status string `json:"status"`
SetType string `json:"set_type"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubModulePractices(ctx context.Context, subModuleID int64) ([]GetSubModulePracticesRow, error) {
rows, err := q.db.Query(ctx, GetSubModulePractices, subModuleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubModulePracticesRow
for rows.Next() {
var i GetSubModulePracticesRow
if err := rows.Scan(
&i.ID,
&i.SubModuleID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.IntroVideoUrl,
&i.QuestionSetID,
&i.DisplayOrder,
&i.IsActive,
&i.Status,
&i.SetType,
&i.QuestionCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubModuleVideos = `-- name: GetSubModuleVideos :many
SELECT id, sub_module_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider, created_at
FROM sub_module_videos
WHERE sub_module_id = $1
AND status != 'ARCHIVED'
ORDER BY display_order ASC, id ASC
`
func (q *Queries) GetSubModuleVideos(ctx context.Context, subModuleID int64) ([]SubModuleVideo, error) {
rows, err := q.db.Query(ctx, GetSubModuleVideos, subModuleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SubModuleVideo
for rows.Next() {
var i SubModuleVideo
if err := rows.Scan(
&i.ID,
&i.SubModuleID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubModulesByModuleID = `-- name: GetSubModulesByModuleID :many
SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id
FROM sub_modules
WHERE module_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC
`
func (q *Queries) GetSubModulesByModuleID(ctx context.Context, moduleID int64) ([]SubModule, error) {
rows, err := q.db.Query(ctx, GetSubModulesByModuleID, moduleID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SubModule
for rows.Next() {
var i SubModule
if err := rows.Scan(
&i.ID,
&i.ModuleID,
&i.Title,
&i.Description,
&i.DisplayOrder,
&i.IsActive,
&i.CreatedAt,
&i.LegacySubCourseID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -1,285 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: learning_tree.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const GetCourseLearningPath = `-- name: GetCourseLearningPath :many
SELECT
c.id AS course_id,
c.title AS course_title,
c.description AS course_description,
c.thumbnail AS course_thumbnail,
c.intro_video_url AS course_intro_video_url,
cc.id AS category_id,
cc.name AS category_name,
sc.id AS sub_course_id,
sc.title AS sub_course_title,
sc.description AS sub_course_description,
sc.thumbnail AS sub_course_thumbnail,
sc.display_order AS sub_course_display_order,
sc.level AS sub_course_level,
sc.sub_level AS sub_course_sub_level,
(SELECT COUNT(*) FROM sub_course_prerequisites WHERE sub_course_id = sc.id) AS prerequisite_count,
(SELECT COUNT(*) FROM sub_course_videos WHERE sub_course_id = sc.id AND status = 'PUBLISHED') AS video_count,
(SELECT COUNT(*) FROM question_sets WHERE set_type = 'PRACTICE' AND owner_type = 'SUB_COURSE' AND owner_id = sc.id AND status = 'PUBLISHED') AS practice_count
FROM courses c
JOIN course_categories cc ON cc.id = c.category_id
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.id = $1
ORDER BY sc.display_order, sc.id
`
type GetCourseLearningPathRow struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
CourseDescription pgtype.Text `json:"course_description"`
CourseThumbnail pgtype.Text `json:"course_thumbnail"`
CourseIntroVideoUrl pgtype.Text `json:"course_intro_video_url"`
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
SubCourseID pgtype.Int8 `json:"sub_course_id"`
SubCourseTitle pgtype.Text `json:"sub_course_title"`
SubCourseDescription pgtype.Text `json:"sub_course_description"`
SubCourseThumbnail pgtype.Text `json:"sub_course_thumbnail"`
SubCourseDisplayOrder pgtype.Int4 `json:"sub_course_display_order"`
SubCourseLevel pgtype.Text `json:"sub_course_level"`
SubCourseSubLevel pgtype.Text `json:"sub_course_sub_level"`
PrerequisiteCount int64 `json:"prerequisite_count"`
VideoCount int64 `json:"video_count"`
PracticeCount int64 `json:"practice_count"`
}
func (q *Queries) GetCourseLearningPath(ctx context.Context, id int64) ([]GetCourseLearningPathRow, error) {
rows, err := q.db.Query(ctx, GetCourseLearningPath, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetCourseLearningPathRow
for rows.Next() {
var i GetCourseLearningPathRow
if err := rows.Scan(
&i.CourseID,
&i.CourseTitle,
&i.CourseDescription,
&i.CourseThumbnail,
&i.CourseIntroVideoUrl,
&i.CategoryID,
&i.CategoryName,
&i.SubCourseID,
&i.SubCourseTitle,
&i.SubCourseDescription,
&i.SubCourseThumbnail,
&i.SubCourseDisplayOrder,
&i.SubCourseLevel,
&i.SubCourseSubLevel,
&i.PrerequisiteCount,
&i.VideoCount,
&i.PracticeCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetFullLearningTree = `-- name: GetFullLearningTree :many
SELECT
c.id AS course_id,
c.title AS course_title,
sc.id AS sub_course_id,
sc.title AS sub_course_title,
sc.level AS sub_course_level,
sc.sub_level AS sub_course_sub_level
FROM courses c
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.is_active = true
ORDER BY c.id, sc.display_order, sc.id
`
type GetFullLearningTreeRow struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
SubCourseID pgtype.Int8 `json:"sub_course_id"`
SubCourseTitle pgtype.Text `json:"sub_course_title"`
SubCourseLevel pgtype.Text `json:"sub_course_level"`
SubCourseSubLevel pgtype.Text `json:"sub_course_sub_level"`
}
func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTreeRow, error) {
rows, err := q.db.Query(ctx, GetFullLearningTree)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetFullLearningTreeRow
for rows.Next() {
var i GetFullLearningTreeRow
if err := rows.Scan(
&i.CourseID,
&i.CourseTitle,
&i.SubCourseID,
&i.SubCourseTitle,
&i.SubCourseLevel,
&i.SubCourseSubLevel,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubCoursePracticesForLearningPath = `-- name: GetSubCoursePracticesForLearningPath :many
SELECT id, title, description, persona, status, intro_video_url,
(SELECT COUNT(*) FROM question_set_items WHERE set_id = qs.id) AS question_count
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1
AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED'
ORDER BY qs.display_order ASC, qs.created_at
`
type GetSubCoursePracticesForLearningPathRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Persona pgtype.Text `json:"persona"`
Status string `json:"status"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionCount int64 `json:"question_count"`
}
func (q *Queries) GetSubCoursePracticesForLearningPath(ctx context.Context, ownerID pgtype.Int8) ([]GetSubCoursePracticesForLearningPathRow, error) {
rows, err := q.db.Query(ctx, GetSubCoursePracticesForLearningPath, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCoursePracticesForLearningPathRow
for rows.Next() {
var i GetSubCoursePracticesForLearningPathRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.Persona,
&i.Status,
&i.IntroVideoUrl,
&i.QuestionCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubCoursePrerequisitesForLearningPath = `-- name: GetSubCoursePrerequisitesForLearningPath :many
SELECT p.prerequisite_sub_course_id, sc.title, sc.level, sc.sub_level
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
WHERE p.sub_course_id = $1
ORDER BY sc.display_order
`
type GetSubCoursePrerequisitesForLearningPathRow struct {
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
Title string `json:"title"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
}
func (q *Queries) GetSubCoursePrerequisitesForLearningPath(ctx context.Context, subCourseID int64) ([]GetSubCoursePrerequisitesForLearningPathRow, error) {
rows, err := q.db.Query(ctx, GetSubCoursePrerequisitesForLearningPath, subCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCoursePrerequisitesForLearningPathRow
for rows.Next() {
var i GetSubCoursePrerequisitesForLearningPathRow
if err := rows.Scan(
&i.PrerequisiteSubCourseID,
&i.Title,
&i.Level,
&i.SubLevel,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubCourseVideosForLearningPath = `-- name: GetSubCourseVideosForLearningPath :many
SELECT id, title, description, video_url, duration, resolution, display_order,
vimeo_id, vimeo_embed_url, video_host_provider
FROM sub_course_videos
WHERE sub_course_id = $1 AND status = 'PUBLISHED'
ORDER BY display_order, id
`
type GetSubCourseVideosForLearningPathRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
DisplayOrder int32 `json:"display_order"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VideoHostProvider pgtype.Text `json:"video_host_provider"`
}
func (q *Queries) GetSubCourseVideosForLearningPath(ctx context.Context, subCourseID int64) ([]GetSubCourseVideosForLearningPathRow, error) {
rows, err := q.db.Query(ctx, GetSubCourseVideosForLearningPath, subCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCourseVideosForLearningPathRow
for rows.Next() {
var i GetSubCourseVideosForLearningPathRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.DisplayOrder,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VideoHostProvider,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -31,6 +31,7 @@ type Course struct {
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
SubCategoryID pgtype.Int8 `json:"sub_category_id"`
}
type CourseCategory struct {
@ -41,6 +42,16 @@ type CourseCategory struct {
DisplayOrder int32 `json:"display_order"`
}
type CourseSubCategory struct {
ID int64 `json:"id"`
CategoryID int64 `json:"category_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
IsActive bool `json:"is_active"`
DisplayOrder int32 `json:"display_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Device struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
@ -58,11 +69,30 @@ type GlobalSetting struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Level struct {
ID int64 `json:"id"`
CourseID int64 `json:"course_id"`
CefrLevel string `json:"cefr_level"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type LevelToSubCourse struct {
LevelID int64 `json:"level_id"`
SubCourseID int64 `json:"sub_course_id"`
}
type Module struct {
ID int64 `json:"id"`
LevelID int64 `json:"level_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type ModuleToSubCourse struct {
ModuleID int64 `json:"module_id"`
SubCourseID int64 `json:"sub_course_id"`
@ -314,6 +344,63 @@ type SubCourseVideo struct {
VideoHostProvider pgtype.Text `json:"video_host_provider"`
}
type SubModule struct {
ID int64 `json:"id"`
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
LegacySubCourseID pgtype.Int8 `json:"legacy_sub_course_id"`
}
type SubModuleLesson struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type SubModulePractice struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder int32 `json:"display_order"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type SubModuleVideo struct {
ID int64 `json:"id"`
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration pgtype.Int4 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
IsPublished bool `json:"is_published"`
PublishDate pgtype.Timestamptz `json:"publish_date"`
Visibility pgtype.Text `json:"visibility"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
VimeoStatus pgtype.Text `json:"vimeo_status"`
VideoHostProvider pgtype.Text `json:"video_host_provider"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type SubscriptionPlan struct {
ID int64 `json:"id"`
Name string `json:"name"`
@ -404,7 +491,7 @@ type UserAudioResponse struct {
type UserPracticeProgress struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
SubCourseID pgtype.Int8 `json:"sub_course_id"`
QuestionSetID int64 `json:"question_set_id"`
CompletedAt pgtype.Timestamp `json:"completed_at"`
CreatedAt pgtype.Timestamp `json:"created_at"`

View File

@ -0,0 +1,98 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: practice_progress.sql
package dbgen
import (
"context"
)
const GetFirstIncompletePreviousPractice = `-- name: GetFirstIncompletePreviousPractice :one
WITH target AS (
SELECT id, owner_type, owner_id, COALESCE(display_order, 0) AS display_order
FROM question_sets
WHERE id = $2::BIGINT
AND set_type = 'PRACTICE'
AND status = 'PUBLISHED'
),
candidates AS (
SELECT qs.id, qs.title, COALESCE(qs.display_order, 0) AS display_order
FROM question_sets qs
JOIN target t
ON qs.owner_type = t.owner_type
AND qs.owner_id = t.owner_id
WHERE qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
AND COALESCE(qs.display_order, 0) < t.display_order
)
SELECT c.id, c.title, c.display_order
FROM candidates c
LEFT JOIN user_practice_progress upp
ON upp.question_set_id = c.id
AND upp.user_id = $1::BIGINT
AND upp.completed_at IS NOT NULL
WHERE upp.id IS NULL
ORDER BY c.display_order ASC, c.id ASC
LIMIT 1
`
type GetFirstIncompletePreviousPracticeParams struct {
UserID int64 `json:"user_id"`
QuestionSetID int64 `json:"question_set_id"`
}
type GetFirstIncompletePreviousPracticeRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
DisplayOrder int32 `json:"display_order"`
}
func (q *Queries) GetFirstIncompletePreviousPractice(ctx context.Context, arg GetFirstIncompletePreviousPracticeParams) (GetFirstIncompletePreviousPracticeRow, error) {
row := q.db.QueryRow(ctx, GetFirstIncompletePreviousPractice, arg.UserID, arg.QuestionSetID)
var i GetFirstIncompletePreviousPracticeRow
err := row.Scan(&i.ID, &i.Title, &i.DisplayOrder)
return i, err
}
const MarkPracticeCompleted = `-- name: MarkPracticeCompleted :execrows
INSERT INTO user_practice_progress (
user_id,
sub_course_id,
question_set_id,
completed_at,
updated_at
)
SELECT
$1::BIGINT,
CASE
WHEN qs.owner_type = 'SUB_COURSE' THEN qs.owner_id
WHEN qs.owner_type = 'SUB_MODULE' THEN sm.legacy_sub_course_id
ELSE NULL
END,
qs.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM question_sets qs
LEFT JOIN sub_modules sm
ON qs.owner_type = 'SUB_MODULE'
AND qs.owner_id = sm.id
WHERE qs.id = $2::BIGINT
ON CONFLICT (user_id, question_set_id) DO UPDATE
SET completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at
`
type MarkPracticeCompletedParams struct {
UserID int64 `json:"user_id"`
QuestionSetID int64 `json:"question_set_id"`
}
func (q *Queries) MarkPracticeCompleted(ctx context.Context, arg MarkPracticeCompletedParams) (int64, error) {
result, err := q.db.Exec(ctx, MarkPracticeCompleted, arg.UserID, arg.QuestionSetID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}

View File

@ -1,187 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: sub_course_prerequisites.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const AddSubCoursePrerequisite = `-- name: AddSubCoursePrerequisite :one
INSERT INTO sub_course_prerequisites (sub_course_id, prerequisite_sub_course_id)
VALUES ($1, $2)
RETURNING id, sub_course_id, prerequisite_sub_course_id, created_at
`
type AddSubCoursePrerequisiteParams struct {
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
}
func (q *Queries) AddSubCoursePrerequisite(ctx context.Context, arg AddSubCoursePrerequisiteParams) (SubCoursePrerequisite, error) {
row := q.db.QueryRow(ctx, AddSubCoursePrerequisite, arg.SubCourseID, arg.PrerequisiteSubCourseID)
var i SubCoursePrerequisite
err := row.Scan(
&i.ID,
&i.SubCourseID,
&i.PrerequisiteSubCourseID,
&i.CreatedAt,
)
return i, err
}
const CountUnmetPrerequisites = `-- name: CountUnmetPrerequisites :one
SELECT COUNT(*)::bigint AS unmet_count
FROM sub_course_prerequisites p
WHERE p.sub_course_id = $1
AND p.prerequisite_sub_course_id NOT IN (
SELECT usp.sub_course_id
FROM user_sub_course_progress usp
WHERE usp.user_id = $2
AND usp.status = 'COMPLETED'
)
`
type CountUnmetPrerequisitesParams struct {
SubCourseID int64 `json:"sub_course_id"`
UserID int64 `json:"user_id"`
}
func (q *Queries) CountUnmetPrerequisites(ctx context.Context, arg CountUnmetPrerequisitesParams) (int64, error) {
row := q.db.QueryRow(ctx, CountUnmetPrerequisites, arg.SubCourseID, arg.UserID)
var unmet_count int64
err := row.Scan(&unmet_count)
return unmet_count, err
}
const DeleteAllPrerequisitesForSubCourse = `-- name: DeleteAllPrerequisitesForSubCourse :exec
DELETE FROM sub_course_prerequisites
WHERE sub_course_id = $1
`
func (q *Queries) DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error {
_, err := q.db.Exec(ctx, DeleteAllPrerequisitesForSubCourse, subCourseID)
return err
}
const GetSubCourseDependents = `-- name: GetSubCourseDependents :many
SELECT
p.id,
p.sub_course_id,
p.prerequisite_sub_course_id,
p.created_at,
sc.title AS dependent_title,
sc.level AS dependent_level
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.sub_course_id
WHERE p.prerequisite_sub_course_id = $1
ORDER BY sc.display_order
`
type GetSubCourseDependentsRow struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
DependentTitle string `json:"dependent_title"`
DependentLevel string `json:"dependent_level"`
}
func (q *Queries) GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]GetSubCourseDependentsRow, error) {
rows, err := q.db.Query(ctx, GetSubCourseDependents, prerequisiteSubCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCourseDependentsRow
for rows.Next() {
var i GetSubCourseDependentsRow
if err := rows.Scan(
&i.ID,
&i.SubCourseID,
&i.PrerequisiteSubCourseID,
&i.CreatedAt,
&i.DependentTitle,
&i.DependentLevel,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubCoursePrerequisites = `-- name: GetSubCoursePrerequisites :many
SELECT
p.id,
p.sub_course_id,
p.prerequisite_sub_course_id,
p.created_at,
sc.title AS prerequisite_title,
sc.level AS prerequisite_level,
sc.display_order AS prerequisite_display_order
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
WHERE p.sub_course_id = $1
ORDER BY sc.display_order
`
type GetSubCoursePrerequisitesRow struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
PrerequisiteTitle string `json:"prerequisite_title"`
PrerequisiteLevel string `json:"prerequisite_level"`
PrerequisiteDisplayOrder int32 `json:"prerequisite_display_order"`
}
func (q *Queries) GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]GetSubCoursePrerequisitesRow, error) {
rows, err := q.db.Query(ctx, GetSubCoursePrerequisites, subCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCoursePrerequisitesRow
for rows.Next() {
var i GetSubCoursePrerequisitesRow
if err := rows.Scan(
&i.ID,
&i.SubCourseID,
&i.PrerequisiteSubCourseID,
&i.CreatedAt,
&i.PrerequisiteTitle,
&i.PrerequisiteLevel,
&i.PrerequisiteDisplayOrder,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const RemoveSubCoursePrerequisite = `-- name: RemoveSubCoursePrerequisite :exec
DELETE FROM sub_course_prerequisites
WHERE sub_course_id = $1 AND prerequisite_sub_course_id = $2
`
type RemoveSubCoursePrerequisiteParams struct {
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
}
func (q *Queries) RemoveSubCoursePrerequisite(ctx context.Context, arg RemoveSubCoursePrerequisiteParams) error {
_, err := q.db.Exec(ctx, RemoveSubCoursePrerequisite, arg.SubCourseID, arg.PrerequisiteSubCourseID)
return err
}

View File

@ -1,441 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: sub_course_videos.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const ArchiveSubCourseVideo = `-- name: ArchiveSubCourseVideo :exec
UPDATE sub_course_videos
SET status = 'ARCHIVED'
WHERE id = $1
`
func (q *Queries) ArchiveSubCourseVideo(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, ArchiveSubCourseVideo, id)
return err
}
const CreateSubCourseVideo = `-- name: CreateSubCourseVideo :one
INSERT INTO sub_course_videos (
sub_course_id,
title,
description,
video_url,
duration,
resolution,
instructor_id,
thumbnail,
visibility,
display_order,
status,
vimeo_id,
vimeo_embed_url,
vimeo_player_html,
vimeo_status,
video_host_provider
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
COALESCE($10, 0),
COALESCE($11, 'DRAFT'),
$12, $13, $14,
COALESCE($15, 'pending'),
COALESCE($16, 'DIRECT')
)
RETURNING id, sub_course_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider
`
type CreateSubCourseVideoParams struct {
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
Visibility pgtype.Text `json:"visibility"`
Column10 interface{} `json:"column_10"`
Column11 interface{} `json:"column_11"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
Column15 interface{} `json:"column_15"`
Column16 interface{} `json:"column_16"`
}
func (q *Queries) CreateSubCourseVideo(ctx context.Context, arg CreateSubCourseVideoParams) (SubCourseVideo, error) {
row := q.db.QueryRow(ctx, CreateSubCourseVideo,
arg.SubCourseID,
arg.Title,
arg.Description,
arg.VideoUrl,
arg.Duration,
arg.Resolution,
arg.InstructorID,
arg.Thumbnail,
arg.Visibility,
arg.Column10,
arg.Column11,
arg.VimeoID,
arg.VimeoEmbedUrl,
arg.VimeoPlayerHtml,
arg.Column15,
arg.Column16,
)
var i SubCourseVideo
err := row.Scan(
&i.ID,
&i.SubCourseID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
)
return i, err
}
const DeleteSubCourseVideo = `-- name: DeleteSubCourseVideo :exec
DELETE FROM sub_course_videos
WHERE id = $1
`
func (q *Queries) DeleteSubCourseVideo(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteSubCourseVideo, id)
return err
}
const GetPublishedVideosBySubCourse = `-- name: GetPublishedVideosBySubCourse :many
SELECT id, sub_course_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider
FROM sub_course_videos
WHERE sub_course_id = $1
AND status = 'PUBLISHED'
ORDER BY display_order ASC, publish_date ASC
`
func (q *Queries) GetPublishedVideosBySubCourse(ctx context.Context, subCourseID int64) ([]SubCourseVideo, error) {
rows, err := q.db.Query(ctx, GetPublishedVideosBySubCourse, subCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SubCourseVideo
for rows.Next() {
var i SubCourseVideo
if err := rows.Scan(
&i.ID,
&i.SubCourseID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetSubCourseVideoByID = `-- name: GetSubCourseVideoByID :one
SELECT id, sub_course_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider
FROM sub_course_videos
WHERE id = $1
`
func (q *Queries) GetSubCourseVideoByID(ctx context.Context, id int64) (SubCourseVideo, error) {
row := q.db.QueryRow(ctx, GetSubCourseVideoByID, id)
var i SubCourseVideo
err := row.Scan(
&i.ID,
&i.SubCourseID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
)
return i, err
}
const GetVideosBySubCourse = `-- name: GetVideosBySubCourse :many
SELECT
COUNT(*) OVER () AS total_count,
id,
sub_course_id,
title,
description,
video_url,
duration,
resolution,
is_published,
publish_date,
visibility,
instructor_id,
thumbnail,
display_order,
status,
vimeo_id,
vimeo_embed_url,
vimeo_player_html,
vimeo_status,
video_host_provider
FROM sub_course_videos
WHERE sub_course_id = $1
AND status != 'ARCHIVED'
ORDER BY display_order ASC, id ASC
`
type GetVideosBySubCourseRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
IsPublished bool `json:"is_published"`
PublishDate pgtype.Timestamptz `json:"publish_date"`
Visibility pgtype.Text `json:"visibility"`
InstructorID pgtype.Text `json:"instructor_id"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
VimeoStatus pgtype.Text `json:"vimeo_status"`
VideoHostProvider pgtype.Text `json:"video_host_provider"`
}
func (q *Queries) GetVideosBySubCourse(ctx context.Context, subCourseID int64) ([]GetVideosBySubCourseRow, error) {
rows, err := q.db.Query(ctx, GetVideosBySubCourse, subCourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetVideosBySubCourseRow
for rows.Next() {
var i GetVideosBySubCourseRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.SubCourseID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetVideosByVimeoID = `-- name: GetVideosByVimeoID :one
SELECT id, sub_course_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider FROM sub_course_videos
WHERE vimeo_id = $1
`
func (q *Queries) GetVideosByVimeoID(ctx context.Context, vimeoID pgtype.Text) (SubCourseVideo, error) {
row := q.db.QueryRow(ctx, GetVideosByVimeoID, vimeoID)
var i SubCourseVideo
err := row.Scan(
&i.ID,
&i.SubCourseID,
&i.Title,
&i.Description,
&i.VideoUrl,
&i.Duration,
&i.Resolution,
&i.IsPublished,
&i.PublishDate,
&i.Visibility,
&i.InstructorID,
&i.Thumbnail,
&i.DisplayOrder,
&i.Status,
&i.VimeoID,
&i.VimeoEmbedUrl,
&i.VimeoPlayerHtml,
&i.VimeoStatus,
&i.VideoHostProvider,
)
return i, err
}
const PublishSubCourseVideo = `-- name: PublishSubCourseVideo :exec
UPDATE sub_course_videos
SET
is_published = true,
publish_date = CURRENT_TIMESTAMP,
status = 'PUBLISHED'
WHERE id = $1
`
func (q *Queries) PublishSubCourseVideo(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, PublishSubCourseVideo, id)
return err
}
const ReorderSubCourseVideos = `-- name: ReorderSubCourseVideos :exec
UPDATE sub_course_videos
SET display_order = bulk.position
FROM (
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
) AS bulk
WHERE sub_course_videos.id = bulk.id
`
type ReorderSubCourseVideosParams struct {
Ids []int64 `json:"ids"`
Positions []int32 `json:"positions"`
}
func (q *Queries) ReorderSubCourseVideos(ctx context.Context, arg ReorderSubCourseVideosParams) error {
_, err := q.db.Exec(ctx, ReorderSubCourseVideos, arg.Ids, arg.Positions)
return err
}
const UpdateSubCourseVideo = `-- name: UpdateSubCourseVideo :exec
UPDATE sub_course_videos
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
video_url = COALESCE($3, video_url),
duration = COALESCE($4, duration),
resolution = COALESCE($5, resolution),
visibility = COALESCE($6, visibility),
thumbnail = COALESCE($7, thumbnail),
display_order = COALESCE($8, display_order),
status = COALESCE($9, status),
vimeo_id = COALESCE($10, vimeo_id),
vimeo_embed_url = COALESCE($11, vimeo_embed_url),
vimeo_player_html = COALESCE($12, vimeo_player_html),
vimeo_status = COALESCE($13, vimeo_status),
video_host_provider = COALESCE($14, video_host_provider)
WHERE id = $15
`
type UpdateSubCourseVideoParams struct {
Title string `json:"title"`
Description pgtype.Text `json:"description"`
VideoUrl string `json:"video_url"`
Duration int32 `json:"duration"`
Resolution pgtype.Text `json:"resolution"`
Visibility pgtype.Text `json:"visibility"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Status string `json:"status"`
VimeoID pgtype.Text `json:"vimeo_id"`
VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"`
VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"`
VimeoStatus pgtype.Text `json:"vimeo_status"`
VideoHostProvider pgtype.Text `json:"video_host_provider"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateSubCourseVideo(ctx context.Context, arg UpdateSubCourseVideoParams) error {
_, err := q.db.Exec(ctx, UpdateSubCourseVideo,
arg.Title,
arg.Description,
arg.VideoUrl,
arg.Duration,
arg.Resolution,
arg.Visibility,
arg.Thumbnail,
arg.DisplayOrder,
arg.Status,
arg.VimeoID,
arg.VimeoEmbedUrl,
arg.VimeoPlayerHtml,
arg.VimeoStatus,
arg.VideoHostProvider,
arg.ID,
)
return err
}
const UpdateVimeoStatus = `-- name: UpdateVimeoStatus :exec
UPDATE sub_course_videos
SET
vimeo_status = $1
WHERE id = $2
`
type UpdateVimeoStatusParams struct {
VimeoStatus pgtype.Text `json:"vimeo_status"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateVimeoStatus(ctx context.Context, arg UpdateVimeoStatusParams) error {
_, err := q.db.Exec(ctx, UpdateVimeoStatus, arg.VimeoStatus, arg.ID)
return err
}

View File

@ -1,356 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: sub_courses.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateSubCourse = `-- name: CreateSubCourse :one
INSERT INTO sub_courses (
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
)
VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, $7, COALESCE($8, true))
RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active, sub_level
`
type CreateSubCourseParams struct {
CourseID int64 `json:"course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
Column5 interface{} `json:"column_5"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
Column8 interface{} `json:"column_8"`
}
func (q *Queries) CreateSubCourse(ctx context.Context, arg CreateSubCourseParams) (SubCourse, error) {
row := q.db.QueryRow(ctx, CreateSubCourse,
arg.CourseID,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.Column5,
arg.Level,
arg.SubLevel,
arg.Column8,
)
var i SubCourse
err := row.Scan(
&i.ID,
&i.CourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.IsActive,
&i.SubLevel,
)
return i, err
}
const DeactivateSubCourse = `-- name: DeactivateSubCourse :exec
UPDATE sub_courses
SET is_active = FALSE
WHERE id = $1
`
func (q *Queries) DeactivateSubCourse(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeactivateSubCourse, id)
return err
}
const DeleteSubCourse = `-- name: DeleteSubCourse :one
DELETE FROM sub_courses
WHERE id = $1
RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active, sub_level
`
func (q *Queries) DeleteSubCourse(ctx context.Context, id int64) (SubCourse, error) {
row := q.db.QueryRow(ctx, DeleteSubCourse, id)
var i SubCourse
err := row.Scan(
&i.ID,
&i.CourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.IsActive,
&i.SubLevel,
)
return i, err
}
const GetSubCourseByID = `-- name: GetSubCourseByID :one
SELECT id, course_id, title, description, thumbnail, display_order, level, is_active, sub_level
FROM sub_courses
WHERE id = $1
`
func (q *Queries) GetSubCourseByID(ctx context.Context, id int64) (SubCourse, error) {
row := q.db.QueryRow(ctx, GetSubCourseByID, id)
var i SubCourse
err := row.Scan(
&i.ID,
&i.CourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.IsActive,
&i.SubLevel,
)
return i, err
}
const GetSubCoursesByCourse = `-- name: GetSubCoursesByCourse :many
SELECT
COUNT(*) OVER () AS total_count,
id,
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE course_id = $1
ORDER BY display_order ASC, id ASC
`
type GetSubCoursesByCourseRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
CourseID int64 `json:"course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
}
func (q *Queries) GetSubCoursesByCourse(ctx context.Context, courseID int64) ([]GetSubCoursesByCourseRow, error) {
rows, err := q.db.Query(ctx, GetSubCoursesByCourse, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCoursesByCourseRow
for rows.Next() {
var i GetSubCoursesByCourseRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.CourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.SubLevel,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListActiveSubCourses = `-- name: ListActiveSubCourses :many
SELECT
id,
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE is_active = TRUE
ORDER BY display_order ASC
`
type ListActiveSubCoursesRow struct {
ID int64 `json:"id"`
CourseID int64 `json:"course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
}
func (q *Queries) ListActiveSubCourses(ctx context.Context) ([]ListActiveSubCoursesRow, error) {
rows, err := q.db.Query(ctx, ListActiveSubCourses)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListActiveSubCoursesRow
for rows.Next() {
var i ListActiveSubCoursesRow
if err := rows.Scan(
&i.ID,
&i.CourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.SubLevel,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListSubCoursesByCourse = `-- name: ListSubCoursesByCourse :many
SELECT
id,
course_id,
title,
description,
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE course_id = $1
AND is_active = TRUE
ORDER BY display_order ASC, id ASC
`
type ListSubCoursesByCourseRow struct {
ID int64 `json:"id"`
CourseID int64 `json:"course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
}
func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([]ListSubCoursesByCourseRow, error) {
rows, err := q.db.Query(ctx, ListSubCoursesByCourse, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListSubCoursesByCourseRow
for rows.Next() {
var i ListSubCoursesByCourseRow
if err := rows.Scan(
&i.ID,
&i.CourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.SubLevel,
&i.IsActive,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ReorderSubCourses = `-- name: ReorderSubCourses :exec
UPDATE sub_courses
SET display_order = bulk.position
FROM (
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
) AS bulk
WHERE sub_courses.id = bulk.id
`
type ReorderSubCoursesParams struct {
Ids []int64 `json:"ids"`
Positions []int32 `json:"positions"`
}
func (q *Queries) ReorderSubCourses(ctx context.Context, arg ReorderSubCoursesParams) error {
_, err := q.db.Exec(ctx, ReorderSubCourses, arg.Ids, arg.Positions)
return err
}
const UpdateSubCourse = `-- name: UpdateSubCourse :exec
UPDATE sub_courses
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
thumbnail = COALESCE($3, thumbnail),
display_order = COALESCE($4, display_order),
level = COALESCE($5, level),
sub_level = COALESCE($6, sub_level),
is_active = COALESCE($7, is_active)
WHERE id = $8
`
type UpdateSubCourseParams struct {
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateSubCourse(ctx context.Context, arg UpdateSubCourseParams) error {
_, err := q.db.Exec(ctx, UpdateSubCourse,
arg.Title,
arg.Description,
arg.Thumbnail,
arg.DisplayOrder,
arg.Level,
arg.SubLevel,
arg.IsActive,
arg.ID,
)
return err
}

View File

@ -1,102 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: user_practice_progress.sql
package dbgen
import (
"context"
)
const GetFirstIncompletePreviousPractice = `-- name: GetFirstIncompletePreviousPractice :one
SELECT
p.id,
p.title,
p.display_order
FROM question_sets target
JOIN question_sets p
ON p.owner_type = 'SUB_COURSE'
AND p.owner_id = target.owner_id
AND p.set_type = 'PRACTICE'
AND p.status = 'PUBLISHED'
AND (
p.display_order < target.display_order OR
(p.display_order = target.display_order AND p.id < target.id)
)
LEFT JOIN user_practice_progress upp
ON upp.question_set_id = p.id
AND upp.user_id = $1::BIGINT
AND upp.completed_at IS NOT NULL
WHERE target.id = $2::BIGINT
AND target.set_type = 'PRACTICE'
AND target.owner_type = 'SUB_COURSE'
AND target.status = 'PUBLISHED'
AND upp.question_set_id IS NULL
ORDER BY p.display_order ASC, p.id ASC
LIMIT 1
`
type GetFirstIncompletePreviousPracticeParams struct {
UserID int64 `json:"user_id"`
QuestionSetID int64 `json:"question_set_id"`
}
type GetFirstIncompletePreviousPracticeRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
DisplayOrder int32 `json:"display_order"`
}
func (q *Queries) GetFirstIncompletePreviousPractice(ctx context.Context, arg GetFirstIncompletePreviousPracticeParams) (GetFirstIncompletePreviousPracticeRow, error) {
row := q.db.QueryRow(ctx, GetFirstIncompletePreviousPractice, arg.UserID, arg.QuestionSetID)
var i GetFirstIncompletePreviousPracticeRow
err := row.Scan(&i.ID, &i.Title, &i.DisplayOrder)
return i, err
}
const MarkPracticeCompleted = `-- name: MarkPracticeCompleted :one
INSERT INTO user_practice_progress (
user_id,
sub_course_id,
question_set_id,
completed_at,
updated_at
)
SELECT
$1::BIGINT,
qs.owner_id::BIGINT,
qs.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM question_sets qs
WHERE qs.id = $2::BIGINT
AND qs.set_type = 'PRACTICE'
AND qs.owner_type = 'SUB_COURSE'
AND qs.status = 'PUBLISHED'
ON CONFLICT (user_id, question_set_id)
DO UPDATE SET
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING id, user_id, sub_course_id, question_set_id, completed_at, created_at, updated_at
`
type MarkPracticeCompletedParams struct {
UserID int64 `json:"user_id"`
QuestionSetID int64 `json:"question_set_id"`
}
func (q *Queries) MarkPracticeCompleted(ctx context.Context, arg MarkPracticeCompletedParams) (UserPracticeProgress, error) {
row := q.db.QueryRow(ctx, MarkPracticeCompleted, arg.UserID, arg.QuestionSetID)
var i UserPracticeProgress
err := row.Scan(
&i.ID,
&i.UserID,
&i.SubCourseID,
&i.QuestionSetID,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -1,279 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: user_sub_course_progress.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CompleteSubCourse = `-- name: CompleteSubCourse :exec
UPDATE user_sub_course_progress
SET
status = 'COMPLETED',
progress_percentage = 100,
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = $1 AND sub_course_id = $2
`
type CompleteSubCourseParams struct {
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
}
func (q *Queries) CompleteSubCourse(ctx context.Context, arg CompleteSubCourseParams) error {
_, err := q.db.Exec(ctx, CompleteSubCourse, arg.UserID, arg.SubCourseID)
return err
}
const DeleteUserSubCourseProgress = `-- name: DeleteUserSubCourseProgress :exec
DELETE FROM user_sub_course_progress
WHERE user_id = $1 AND sub_course_id = $2
`
type DeleteUserSubCourseProgressParams struct {
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
}
func (q *Queries) DeleteUserSubCourseProgress(ctx context.Context, arg DeleteUserSubCourseProgressParams) error {
_, err := q.db.Exec(ctx, DeleteUserSubCourseProgress, arg.UserID, arg.SubCourseID)
return err
}
const GetSubCoursesWithProgressByCourse = `-- name: GetSubCoursesWithProgressByCourse :many
SELECT
sc.id AS sub_course_id,
sc.title,
sc.description,
sc.thumbnail,
sc.display_order,
sc.level,
sc.is_active,
COALESCE(usp.status, 'NOT_STARTED') AS progress_status,
COALESCE(usp.progress_percentage, 0)::smallint AS progress_percentage,
usp.started_at,
usp.completed_at,
(SELECT COUNT(*)::bigint
FROM sub_course_prerequisites p
WHERE p.sub_course_id = sc.id
AND p.prerequisite_sub_course_id NOT IN (
SELECT usp2.sub_course_id
FROM user_sub_course_progress usp2
WHERE usp2.user_id = $1
AND usp2.status = 'COMPLETED'
)
) AS unmet_prerequisites_count
FROM sub_courses sc
LEFT JOIN user_sub_course_progress usp
ON usp.sub_course_id = sc.id AND usp.user_id = $1
WHERE sc.course_id = $2
AND sc.is_active = true
ORDER BY sc.display_order
`
type GetSubCoursesWithProgressByCourseParams struct {
UserID int64 `json:"user_id"`
CourseID int64 `json:"course_id"`
}
type GetSubCoursesWithProgressByCourseRow struct {
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
IsActive bool `json:"is_active"`
ProgressStatus string `json:"progress_status"`
ProgressPercentage int16 `json:"progress_percentage"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
UnmetPrerequisitesCount int64 `json:"unmet_prerequisites_count"`
}
func (q *Queries) GetSubCoursesWithProgressByCourse(ctx context.Context, arg GetSubCoursesWithProgressByCourseParams) ([]GetSubCoursesWithProgressByCourseRow, error) {
rows, err := q.db.Query(ctx, GetSubCoursesWithProgressByCourse, arg.UserID, arg.CourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetSubCoursesWithProgressByCourseRow
for rows.Next() {
var i GetSubCoursesWithProgressByCourseRow
if err := rows.Scan(
&i.SubCourseID,
&i.Title,
&i.Description,
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.IsActive,
&i.ProgressStatus,
&i.ProgressPercentage,
&i.StartedAt,
&i.CompletedAt,
&i.UnmetPrerequisitesCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetUserCourseProgress = `-- name: GetUserCourseProgress :many
SELECT
usp.id,
usp.user_id,
usp.sub_course_id,
usp.status,
usp.progress_percentage,
usp.started_at,
usp.completed_at,
usp.created_at,
usp.updated_at,
sc.title AS sub_course_title,
sc.level AS sub_course_level,
sc.display_order AS sub_course_display_order
FROM user_sub_course_progress usp
JOIN sub_courses sc ON sc.id = usp.sub_course_id
WHERE usp.user_id = $1 AND sc.course_id = $2
ORDER BY sc.display_order
`
type GetUserCourseProgressParams struct {
UserID int64 `json:"user_id"`
CourseID int64 `json:"course_id"`
}
type GetUserCourseProgressRow struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
Status string `json:"status"`
ProgressPercentage int16 `json:"progress_percentage"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
SubCourseTitle string `json:"sub_course_title"`
SubCourseLevel string `json:"sub_course_level"`
SubCourseDisplayOrder int32 `json:"sub_course_display_order"`
}
func (q *Queries) GetUserCourseProgress(ctx context.Context, arg GetUserCourseProgressParams) ([]GetUserCourseProgressRow, error) {
rows, err := q.db.Query(ctx, GetUserCourseProgress, arg.UserID, arg.CourseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetUserCourseProgressRow
for rows.Next() {
var i GetUserCourseProgressRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.SubCourseID,
&i.Status,
&i.ProgressPercentage,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseTitle,
&i.SubCourseLevel,
&i.SubCourseDisplayOrder,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetUserSubCourseProgress = `-- name: GetUserSubCourseProgress :one
SELECT id, user_id, sub_course_id, status, progress_percentage, started_at, completed_at, created_at, updated_at FROM user_sub_course_progress
WHERE user_id = $1 AND sub_course_id = $2
`
type GetUserSubCourseProgressParams struct {
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
}
func (q *Queries) GetUserSubCourseProgress(ctx context.Context, arg GetUserSubCourseProgressParams) (UserSubCourseProgress, error) {
row := q.db.QueryRow(ctx, GetUserSubCourseProgress, arg.UserID, arg.SubCourseID)
var i UserSubCourseProgress
err := row.Scan(
&i.ID,
&i.UserID,
&i.SubCourseID,
&i.Status,
&i.ProgressPercentage,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const StartSubCourseProgress = `-- name: StartSubCourseProgress :one
INSERT INTO user_sub_course_progress (user_id, sub_course_id)
VALUES ($1, $2)
ON CONFLICT (user_id, sub_course_id) DO NOTHING
RETURNING id, user_id, sub_course_id, status, progress_percentage, started_at, completed_at, created_at, updated_at
`
type StartSubCourseProgressParams struct {
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
}
func (q *Queries) StartSubCourseProgress(ctx context.Context, arg StartSubCourseProgressParams) (UserSubCourseProgress, error) {
row := q.db.QueryRow(ctx, StartSubCourseProgress, arg.UserID, arg.SubCourseID)
var i UserSubCourseProgress
err := row.Scan(
&i.ID,
&i.UserID,
&i.SubCourseID,
&i.Status,
&i.ProgressPercentage,
&i.StartedAt,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const UpdateSubCourseProgress = `-- name: UpdateSubCourseProgress :exec
UPDATE user_sub_course_progress
SET
progress_percentage = $1,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = $2 AND sub_course_id = $3
`
type UpdateSubCourseProgressParams struct {
ProgressPercentage int16 `json:"progress_percentage"`
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
}
func (q *Queries) UpdateSubCourseProgress(ctx context.Context, arg UpdateSubCourseProgressParams) error {
_, err := q.db.Exec(ctx, UpdateSubCourseProgress, arg.ProgressPercentage, arg.UserID, arg.SubCourseID)
return err
}

View File

@ -1,95 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: user_sub_course_video_progress.sql
package dbgen
import (
"context"
)
const GetFirstIncompletePreviousVideo = `-- name: GetFirstIncompletePreviousVideo :one
SELECT
v.id,
v.title,
v.display_order
FROM sub_course_videos target
JOIN sub_course_videos v
ON v.sub_course_id = target.sub_course_id
AND v.status = 'PUBLISHED'
AND (
v.display_order < target.display_order OR
(v.display_order = target.display_order AND v.id < target.id)
)
LEFT JOIN user_sub_course_video_progress p
ON p.video_id = v.id
AND p.user_id = $1::BIGINT
AND p.completed_at IS NOT NULL
WHERE target.id = $2::BIGINT
AND p.video_id IS NULL
ORDER BY v.display_order ASC, v.id ASC
LIMIT 1
`
type GetFirstIncompletePreviousVideoParams struct {
UserID int64 `json:"user_id"`
VideoID int64 `json:"video_id"`
}
type GetFirstIncompletePreviousVideoRow struct {
ID int64 `json:"id"`
Title string `json:"title"`
DisplayOrder int32 `json:"display_order"`
}
func (q *Queries) GetFirstIncompletePreviousVideo(ctx context.Context, arg GetFirstIncompletePreviousVideoParams) (GetFirstIncompletePreviousVideoRow, error) {
row := q.db.QueryRow(ctx, GetFirstIncompletePreviousVideo, arg.UserID, arg.VideoID)
var i GetFirstIncompletePreviousVideoRow
err := row.Scan(&i.ID, &i.Title, &i.DisplayOrder)
return i, err
}
const MarkVideoCompleted = `-- name: MarkVideoCompleted :one
INSERT INTO user_sub_course_video_progress (
user_id,
sub_course_id,
video_id,
completed_at,
updated_at
)
SELECT
$1::BIGINT,
v.sub_course_id,
v.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM sub_course_videos v
WHERE v.id = $2::BIGINT
AND v.status = 'PUBLISHED'
ON CONFLICT (user_id, video_id)
DO UPDATE SET
completed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
RETURNING id, user_id, sub_course_id, video_id, completed_at, created_at, updated_at
`
type MarkVideoCompletedParams struct {
UserID int64 `json:"user_id"`
VideoID int64 `json:"video_id"`
}
func (q *Queries) MarkVideoCompleted(ctx context.Context, arg MarkVideoCompletedParams) (UserSubCourseVideoProgress, error) {
row := q.db.QueryRow(ctx, MarkVideoCompleted, arg.UserID, arg.VideoID)
var i UserSubCourseVideoProgress
err := row.Scan(
&i.ID,
&i.UserID,
&i.SubCourseID,
&i.VideoID,
&i.CompletedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -3,13 +3,13 @@ package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"github.com/jackc/pgx/v5/pgtype"
)
func NewCourseStore(s *Store) ports.CourseStore { return s }
func NewCourseStore(s *Store) *Store { return s }
func NewProgressionStore(s *Store) *Store { return s }
func (s *Store) CreateCourseCategory(
ctx context.Context,

View File

@ -1,162 +0,0 @@
package repository
import (
"Yimaru-Backend/internal/domain"
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) {
rows, err := s.queries.GetFullLearningTree(ctx)
if err != nil {
return nil, err
}
coursesMap := make(map[int64]*domain.TreeCourse)
for _, row := range rows {
course, ok := coursesMap[row.CourseID]
if !ok {
course = &domain.TreeCourse{
ID: row.CourseID,
Title: row.CourseTitle,
SubCourses: []domain.TreeSubCourse{},
}
coursesMap[row.CourseID] = course
}
if row.SubCourseID.Valid {
subCourse := domain.TreeSubCourse{
ID: row.SubCourseID.Int64,
Title: row.SubCourseTitle.String,
Level: row.SubCourseLevel.String,
SubLevel: row.SubCourseSubLevel.String,
}
course.SubCourses = append(course.SubCourses, subCourse)
}
}
courses := make([]domain.TreeCourse, 0, len(coursesMap))
for _, course := range coursesMap {
courses = append(courses, *course)
}
return courses, nil
}
func (s *Store) GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) {
rows, err := s.queries.GetCourseLearningPath(ctx, courseID)
if err != nil {
return domain.LearningPath{}, err
}
if len(rows) == 0 {
return domain.LearningPath{}, fmt.Errorf("course not found")
}
first := rows[0]
path := domain.LearningPath{
CourseID: first.CourseID,
CourseTitle: first.CourseTitle,
Description: ptrString(first.CourseDescription),
Thumbnail: ptrString(first.CourseThumbnail),
IntroVideoURL: ptrString(first.CourseIntroVideoUrl),
CategoryID: first.CategoryID,
CategoryName: first.CategoryName,
SubCourses: []domain.LearningPathSubCourse{},
}
for _, row := range rows {
if !row.SubCourseID.Valid {
continue
}
scID := row.SubCourseID.Int64
// Fetch prerequisites, videos, practices for this sub-course
prerequisites, _ := s.getSubCoursePrerequisitesForPath(ctx, scID)
videos, _ := s.getSubCourseVideosForPath(ctx, scID)
practices, _ := s.getSubCoursePracticesForPath(ctx, scID)
sc := domain.LearningPathSubCourse{
ID: scID,
Title: row.SubCourseTitle.String,
Description: ptrString(row.SubCourseDescription),
Thumbnail: ptrString(row.SubCourseThumbnail),
DisplayOrder: row.SubCourseDisplayOrder.Int32,
Level: row.SubCourseLevel.String,
SubLevel: row.SubCourseSubLevel.String,
PrerequisiteCount: row.PrerequisiteCount,
VideoCount: row.VideoCount,
PracticeCount: row.PracticeCount,
Prerequisites: prerequisites,
Videos: videos,
Practices: practices,
}
path.SubCourses = append(path.SubCourses, sc)
}
return path, nil
}
func (s *Store) getSubCoursePrerequisitesForPath(ctx context.Context, subCourseID int64) ([]domain.LearningPathPrerequisite, error) {
rows, err := s.queries.GetSubCoursePrerequisitesForLearningPath(ctx, subCourseID)
if err != nil {
return nil, err
}
result := make([]domain.LearningPathPrerequisite, len(rows))
for i, row := range rows {
result[i] = domain.LearningPathPrerequisite{
SubCourseID: row.PrerequisiteSubCourseID,
Title: row.Title,
Level: row.Level,
SubLevel: row.SubLevel,
}
}
return result, nil
}
func (s *Store) getSubCourseVideosForPath(ctx context.Context, subCourseID int64) ([]domain.LearningPathVideo, error) {
rows, err := s.queries.GetSubCourseVideosForLearningPath(ctx, subCourseID)
if err != nil {
return nil, err
}
result := make([]domain.LearningPathVideo, len(rows))
for i, row := range rows {
result[i] = domain.LearningPathVideo{
ID: row.ID,
Title: row.Title,
Description: ptrString(row.Description),
VideoURL: row.VideoUrl,
Duration: row.Duration,
Resolution: ptrString(row.Resolution),
DisplayOrder: row.DisplayOrder,
VimeoID: ptrString(row.VimeoID),
VimeoEmbedURL: ptrString(row.VimeoEmbedUrl),
VideoHostProvider: ptrString(row.VideoHostProvider),
}
}
return result, nil
}
func (s *Store) getSubCoursePracticesForPath(ctx context.Context, subCourseID int64) ([]domain.LearningPathPractice, error) {
ownerID := pgtype.Int8{Int64: subCourseID, Valid: true}
rows, err := s.queries.GetSubCoursePracticesForLearningPath(ctx, ownerID)
if err != nil {
return nil, err
}
result := make([]domain.LearningPathPractice, len(rows))
for i, row := range rows {
result[i] = domain.LearningPathPractice{
ID: row.ID,
Title: row.Title,
Description: ptrString(row.Description),
Persona: ptrString(row.Persona),
Status: row.Status,
IntroVideoURL: ptrString(row.IntroVideoUrl),
QuestionCount: row.QuestionCount,
}
}
return result, nil
}

View File

@ -1,310 +0,0 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
func NewProgressionStore(s *Store) ports.ProgressionStore { return s }
func (s *Store) AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error {
_, err := s.queries.AddSubCoursePrerequisite(ctx, dbgen.AddSubCoursePrerequisiteParams{
SubCourseID: subCourseID,
PrerequisiteSubCourseID: prerequisiteSubCourseID,
})
return err
}
func (s *Store) RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error {
return s.queries.RemoveSubCoursePrerequisite(ctx, dbgen.RemoveSubCoursePrerequisiteParams{
SubCourseID: subCourseID,
PrerequisiteSubCourseID: prerequisiteSubCourseID,
})
}
func (s *Store) GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error) {
rows, err := s.queries.GetSubCoursePrerequisites(ctx, subCourseID)
if err != nil {
return nil, err
}
prereqs := make([]domain.SubCoursePrerequisite, len(rows))
for i, row := range rows {
prereqs[i] = domain.SubCoursePrerequisite{
ID: row.ID,
SubCourseID: row.SubCourseID,
PrerequisiteSubCourseID: row.PrerequisiteSubCourseID,
CreatedAt: row.CreatedAt.Time,
PrerequisiteTitle: row.PrerequisiteTitle,
PrerequisiteLevel: row.PrerequisiteLevel,
PrerequisiteDisplayOrder: row.PrerequisiteDisplayOrder,
}
}
return prereqs, nil
}
func (s *Store) GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error) {
rows, err := s.queries.GetSubCourseDependents(ctx, prerequisiteSubCourseID)
if err != nil {
return nil, err
}
deps := make([]domain.SubCourseDependent, len(rows))
for i, row := range rows {
deps[i] = domain.SubCourseDependent{
ID: row.ID,
SubCourseID: row.SubCourseID,
PrerequisiteSubCourseID: row.PrerequisiteSubCourseID,
CreatedAt: row.CreatedAt.Time,
DependentTitle: row.DependentTitle,
DependentLevel: row.DependentLevel,
}
}
return deps, nil
}
func (s *Store) CountUnmetPrerequisites(ctx context.Context, subCourseID, userID int64) (int64, error) {
return s.queries.CountUnmetPrerequisites(ctx, dbgen.CountUnmetPrerequisitesParams{
SubCourseID: subCourseID,
UserID: userID,
})
}
func (s *Store) DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error {
return s.queries.DeleteAllPrerequisitesForSubCourse(ctx, subCourseID)
}
func (s *Store) StartSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) {
row, err := s.queries.StartSubCourseProgress(ctx, dbgen.StartSubCourseProgressParams{
UserID: userID,
SubCourseID: subCourseID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return s.GetUserSubCourseProgress(ctx, userID, subCourseID)
}
return domain.UserSubCourseProgress{}, err
}
return mapUserSubCourseProgress(row), nil
}
func (s *Store) UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error {
return s.queries.UpdateSubCourseProgress(ctx, dbgen.UpdateSubCourseProgressParams{
ProgressPercentage: percentage,
UserID: userID,
SubCourseID: subCourseID,
})
}
func (s *Store) CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error {
return s.queries.CompleteSubCourse(ctx, dbgen.CompleteSubCourseParams{
UserID: userID,
SubCourseID: subCourseID,
})
}
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,
SubCourseID: subCourseID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.UserSubCourseProgress{}, domain.ErrProgressNotFound
}
return domain.UserSubCourseProgress{}, err
}
return mapUserSubCourseProgress(row), nil
}
func (s *Store) GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error) {
rows, err := s.queries.GetUserCourseProgress(ctx, dbgen.GetUserCourseProgressParams{
UserID: userID,
CourseID: courseID,
})
if err != nil {
return nil, err
}
items := make([]domain.UserCourseProgressItem, len(rows))
for i, row := range rows {
var startedAt, completedAt *time.Time
if row.StartedAt.Valid {
startedAt = &row.StartedAt.Time
}
if row.CompletedAt.Valid {
completedAt = &row.CompletedAt.Time
}
var updatedAt *time.Time
if row.UpdatedAt.Valid {
updatedAt = &row.UpdatedAt.Time
}
items[i] = domain.UserCourseProgressItem{
ID: row.ID,
UserID: row.UserID,
SubCourseID: row.SubCourseID,
Status: domain.ProgressStatus(row.Status),
ProgressPercentage: row.ProgressPercentage,
StartedAt: startedAt,
CompletedAt: completedAt,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: updatedAt,
SubCourseTitle: row.SubCourseTitle,
SubCourseLevel: row.SubCourseLevel,
SubCourseDisplayOrder: row.SubCourseDisplayOrder,
}
}
return items, nil
}
func (s *Store) GetSubCoursesWithProgressByCourse(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error) {
rows, err := s.queries.GetSubCoursesWithProgressByCourse(ctx, dbgen.GetSubCoursesWithProgressByCourseParams{
UserID: userID,
CourseID: courseID,
})
if err != nil {
return nil, err
}
items := make([]domain.SubCourseWithProgress, len(rows))
for i, row := range rows {
var startedAt, completedAt *time.Time
if row.StartedAt.Valid {
startedAt = &row.StartedAt.Time
}
if row.CompletedAt.Valid {
completedAt = &row.CompletedAt.Time
}
items[i] = domain.SubCourseWithProgress{
SubCourseID: row.SubCourseID,
Title: row.Title,
Description: ptrText(row.Description),
Thumbnail: ptrText(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
IsActive: row.IsActive,
ProgressStatus: domain.ProgressStatus(row.ProgressStatus),
ProgressPercentage: row.ProgressPercentage,
StartedAt: startedAt,
CompletedAt: completedAt,
UnmetPrerequisitesCount: row.UnmetPrerequisitesCount,
IsLocked: row.UnmetPrerequisitesCount > 0,
}
}
return items, nil
}
func mapUserSubCourseProgress(row dbgen.UserSubCourseProgress) domain.UserSubCourseProgress {
var startedAt, completedAt *time.Time
if row.StartedAt.Valid {
startedAt = &row.StartedAt.Time
}
if row.CompletedAt.Valid {
completedAt = &row.CompletedAt.Time
}
var updatedAt *time.Time
if row.UpdatedAt.Valid {
updatedAt = &row.UpdatedAt.Time
}
return domain.UserSubCourseProgress{
ID: row.ID,
UserID: row.UserID,
SubCourseID: row.SubCourseID,
Status: domain.ProgressStatus(row.Status),
ProgressPercentage: row.ProgressPercentage,
StartedAt: startedAt,
CompletedAt: completedAt,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: updatedAt,
}
}

View File

@ -1,329 +0,0 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) CreateSubCourseVideo(
ctx context.Context,
subCourseID int64,
title string,
description *string,
videoURL string,
duration int32,
resolution *string,
instructorID *string,
thumbnail *string,
visibility *string,
displayOrder *int32,
status *string,
vimeoID *string,
vimeoEmbedURL *string,
vimeoPlayerHTML *string,
vimeoStatus *string,
videoHostProvider *string,
) (domain.SubCourseVideo, error) {
var descText, resText, instrText, thumbText, visText, statusText pgtype.Text
var vimeoIDText, vimeoEmbedText, vimeoHTMLText, vimeoStatusText, hostProviderText pgtype.Text
if description != nil {
descText = pgtype.Text{String: *description, Valid: true}
}
if resolution != nil {
resText = pgtype.Text{String: *resolution, Valid: true}
}
if instructorID != nil {
instrText = pgtype.Text{String: *instructorID, Valid: true}
}
if thumbnail != nil {
thumbText = pgtype.Text{String: *thumbnail, Valid: true}
}
if visibility != nil {
visText = pgtype.Text{String: *visibility, Valid: true}
}
if status != nil {
statusText = pgtype.Text{String: *status, Valid: true}
}
if vimeoID != nil {
vimeoIDText = pgtype.Text{String: *vimeoID, Valid: true}
}
if vimeoEmbedURL != nil {
vimeoEmbedText = pgtype.Text{String: *vimeoEmbedURL, Valid: true}
}
if vimeoPlayerHTML != nil {
vimeoHTMLText = pgtype.Text{String: *vimeoPlayerHTML, Valid: true}
}
if vimeoStatus != nil {
vimeoStatusText = pgtype.Text{String: *vimeoStatus, Valid: true}
}
if videoHostProvider != nil {
hostProviderText = pgtype.Text{String: *videoHostProvider, Valid: true}
}
var dispOrder pgtype.Int4
if displayOrder != nil {
dispOrder = pgtype.Int4{Int32: *displayOrder, Valid: true}
}
row, err := s.queries.CreateSubCourseVideo(ctx, dbgen.CreateSubCourseVideoParams{
SubCourseID: subCourseID,
Title: title,
Description: descText,
VideoUrl: videoURL,
Duration: duration,
Resolution: resText,
InstructorID: instrText,
Thumbnail: thumbText,
Visibility: visText,
Column10: dispOrder,
Column11: statusText,
VimeoID: vimeoIDText,
VimeoEmbedUrl: vimeoEmbedText,
VimeoPlayerHtml: vimeoHTMLText,
Column15: vimeoStatusText,
Column16: hostProviderText,
})
if err != nil {
return domain.SubCourseVideo{}, err
}
return mapSubCourseVideoRow(row), nil
}
func (s *Store) GetSubCourseVideoByID(
ctx context.Context,
id int64,
) (domain.SubCourseVideo, error) {
row, err := s.queries.GetSubCourseVideoByID(ctx, id)
if err != nil {
return domain.SubCourseVideo{}, err
}
return mapSubCourseVideoRow(row), nil
}
func (s *Store) GetVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, int64, error) {
rows, err := s.queries.GetVideosBySubCourse(ctx, subCourseID)
if err != nil {
return nil, 0, err
}
var (
videos []domain.SubCourseVideo
totalCount int64
)
for i, row := range rows {
if i == 0 {
totalCount = row.TotalCount
}
videos = append(videos, domain.SubCourseVideo{
ID: row.ID,
SubCourseID: row.SubCourseID,
Title: row.Title,
Description: ptrString(row.Description),
VideoURL: row.VideoUrl,
Duration: row.Duration,
Resolution: ptrString(row.Resolution),
InstructorID: ptrString(row.InstructorID),
Thumbnail: ptrString(row.Thumbnail),
Visibility: ptrString(row.Visibility),
DisplayOrder: row.DisplayOrder,
IsPublished: row.IsPublished,
PublishDate: ptrTimestamptz(row.PublishDate),
Status: row.Status,
VimeoID: ptrString(row.VimeoID),
VimeoEmbedURL: ptrString(row.VimeoEmbedUrl),
VimeoPlayerHTML: ptrString(row.VimeoPlayerHtml),
VimeoStatus: ptrString(row.VimeoStatus),
})
}
return videos, totalCount, nil
}
func (s *Store) GetPublishedVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, error) {
rows, err := s.queries.GetPublishedVideosBySubCourse(ctx, subCourseID)
if err != nil {
return nil, err
}
videos := make([]domain.SubCourseVideo, 0, len(rows))
for _, row := range rows {
videos = append(videos, mapSubCourseVideoRow(row))
}
return videos, nil
}
func (s *Store) GetFirstIncompletePreviousVideo(
ctx context.Context,
userID int64,
videoID int64,
) (*domain.VideoAccessBlock, error) {
row, err := s.queries.GetFirstIncompletePreviousVideo(ctx, dbgen.GetFirstIncompletePreviousVideoParams{
UserID: userID,
VideoID: videoID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &domain.VideoAccessBlock{
VideoID: row.ID,
Title: row.Title,
DisplayOrder: row.DisplayOrder,
}, nil
}
func (s *Store) MarkVideoCompleted(
ctx context.Context,
userID int64,
videoID int64,
) error {
_, err := s.queries.MarkVideoCompleted(ctx, dbgen.MarkVideoCompletedParams{
UserID: userID,
VideoID: videoID,
})
return err
}
func (s *Store) PublishSubCourseVideo(
ctx context.Context,
videoID int64,
) error {
return s.queries.PublishSubCourseVideo(ctx, videoID)
}
func (s *Store) UpdateSubCourseVideo(
ctx context.Context,
id int64,
title *string,
description *string,
videoURL *string,
duration *int32,
resolution *string,
visibility *string,
thumbnail *string,
displayOrder *int32,
status *string,
) error {
var titleVal, descVal, urlVal, resVal, visVal, thumbVal, statusVal string
var durationVal, dispOrderVal int32
if title != nil {
titleVal = *title
}
if description != nil {
descVal = *description
}
if videoURL != nil {
urlVal = *videoURL
}
if duration != nil {
durationVal = *duration
}
if resolution != nil {
resVal = *resolution
}
if visibility != nil {
visVal = *visibility
}
if thumbnail != nil {
thumbVal = *thumbnail
}
if displayOrder != nil {
dispOrderVal = *displayOrder
}
if status != nil {
statusVal = *status
}
return s.queries.UpdateSubCourseVideo(ctx, dbgen.UpdateSubCourseVideoParams{
Title: titleVal,
Description: pgtype.Text{String: descVal, Valid: description != nil},
VideoUrl: urlVal,
Duration: durationVal,
Resolution: pgtype.Text{String: resVal, Valid: resolution != nil},
Visibility: pgtype.Text{String: visVal, Valid: visibility != nil},
Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil},
DisplayOrder: dispOrderVal,
Status: statusVal,
ID: id,
})
}
func (s *Store) ArchiveSubCourseVideo(
ctx context.Context,
id int64,
) error {
return s.queries.ArchiveSubCourseVideo(ctx, id)
}
func (s *Store) DeleteSubCourseVideo(
ctx context.Context,
id int64,
) error {
return s.queries.DeleteSubCourseVideo(ctx, id)
}
func mapSubCourseVideoRow(row dbgen.SubCourseVideo) domain.SubCourseVideo {
return domain.SubCourseVideo{
ID: row.ID,
SubCourseID: row.SubCourseID,
Title: row.Title,
Description: ptrString(row.Description),
VideoURL: row.VideoUrl,
Duration: row.Duration,
Resolution: ptrString(row.Resolution),
InstructorID: ptrString(row.InstructorID),
Thumbnail: ptrString(row.Thumbnail),
Visibility: ptrString(row.Visibility),
DisplayOrder: row.DisplayOrder,
IsPublished: row.IsPublished,
PublishDate: ptrTimestamptz(row.PublishDate),
Status: row.Status,
VimeoID: ptrString(row.VimeoID),
VimeoEmbedURL: ptrString(row.VimeoEmbedUrl),
VimeoPlayerHTML: ptrString(row.VimeoPlayerHtml),
VimeoStatus: ptrString(row.VimeoStatus),
}
}
func (s *Store) UpdateVimeoStatus(ctx context.Context, videoID int64, status string) error {
return s.queries.UpdateVimeoStatus(ctx, dbgen.UpdateVimeoStatusParams{
VimeoStatus: pgtype.Text{String: status, Valid: true},
ID: videoID,
})
}
func (s *Store) GetVideoByVimeoID(ctx context.Context, vimeoID string) (domain.SubCourseVideo, error) {
row, err := s.queries.GetVideosByVimeoID(ctx, pgtype.Text{String: vimeoID, Valid: true})
if err != nil {
return domain.SubCourseVideo{}, err
}
return mapSubCourseVideoRow(row), nil
}
func (s *Store) ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error {
return s.queries.ReorderSubCourseVideos(ctx, dbgen.ReorderSubCourseVideosParams{
Ids: ids,
Positions: positions,
})
}

View File

@ -1,256 +0,0 @@
package repository
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"context"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) CreateSubCourse(
ctx context.Context,
courseID int64,
title string,
description *string,
thumbnail *string,
displayOrder *int32,
level string,
subLevel string,
) (domain.SubCourse, error) {
var descText, thumbText pgtype.Text
if description != nil {
descText = pgtype.Text{String: *description, Valid: true}
}
if thumbnail != nil {
thumbText = pgtype.Text{String: *thumbnail, Valid: true}
}
var dispOrder pgtype.Int4
if displayOrder != nil {
dispOrder = pgtype.Int4{Int32: *displayOrder, Valid: true}
}
row, err := s.queries.CreateSubCourse(ctx, dbgen.CreateSubCourseParams{
CourseID: courseID,
Title: title,
Description: descText,
Thumbnail: thumbText,
Column5: dispOrder,
Level: level,
SubLevel: subLevel,
Column8: pgtype.Bool{Bool: true, Valid: true},
})
if err != nil {
return domain.SubCourse{}, err
}
return domain.SubCourse{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Description: ptrString(row.Description),
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
}, nil
}
func (s *Store) GetSubCourseByID(
ctx context.Context,
id int64,
) (domain.SubCourse, error) {
row, err := s.queries.GetSubCourseByID(ctx, id)
if err != nil {
return domain.SubCourse{}, err
}
return domain.SubCourse{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Description: ptrString(row.Description),
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
}, nil
}
func (s *Store) GetSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, int64, error) {
rows, err := s.queries.GetSubCoursesByCourse(ctx, courseID)
if err != nil {
return nil, 0, err
}
var (
subCourses []domain.SubCourse
totalCount int64
)
for i, row := range rows {
if i == 0 {
totalCount = row.TotalCount
}
subCourses = append(subCourses, domain.SubCourse{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Description: ptrString(row.Description),
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
})
}
return subCourses, totalCount, nil
}
func (s *Store) ListSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, error) {
rows, err := s.queries.ListSubCoursesByCourse(ctx, courseID)
if err != nil {
return nil, err
}
subCourses := make([]domain.SubCourse, 0, len(rows))
for _, row := range rows {
subCourses = append(subCourses, domain.SubCourse{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Description: ptrString(row.Description),
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
})
}
return subCourses, nil
}
func (s *Store) ListActiveSubCourses(
ctx context.Context,
) ([]domain.SubCourse, error) {
rows, err := s.queries.ListActiveSubCourses(ctx)
if err != nil {
return nil, err
}
subCourses := make([]domain.SubCourse, 0, len(rows))
for _, row := range rows {
subCourses = append(subCourses, domain.SubCourse{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Description: ptrString(row.Description),
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
})
}
return subCourses, nil
}
func (s *Store) UpdateSubCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
displayOrder *int32,
level *string,
subLevel *string,
isActive *bool,
) error {
var titleVal, descVal, thumbVal, levelVal, subLevelVal string
var dispOrderVal int32
var isActiveVal bool
if title != nil {
titleVal = *title
}
if description != nil {
descVal = *description
}
if thumbnail != nil {
thumbVal = *thumbnail
}
if displayOrder != nil {
dispOrderVal = *displayOrder
}
if level != nil {
levelVal = *level
}
if subLevel != nil {
subLevelVal = *subLevel
}
if isActive != nil {
isActiveVal = *isActive
}
return s.queries.UpdateSubCourse(ctx, dbgen.UpdateSubCourseParams{
Title: titleVal,
Description: pgtype.Text{String: descVal, Valid: description != nil},
Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil},
DisplayOrder: dispOrderVal,
Level: levelVal,
SubLevel: subLevelVal,
IsActive: isActiveVal,
ID: id,
})
}
func (s *Store) DeactivateSubCourse(
ctx context.Context,
id int64,
) error {
return s.queries.DeactivateSubCourse(ctx, id)
}
func (s *Store) DeleteSubCourse(
ctx context.Context,
id int64,
) (domain.SubCourse, error) {
row, err := s.queries.DeleteSubCourse(ctx, id)
if err != nil {
return domain.SubCourse{}, err
}
return domain.SubCourse{
ID: row.ID,
CourseID: row.CourseID,
Title: row.Title,
Description: ptrString(row.Description),
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
}, nil
}
func (s *Store) ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error {
return s.queries.ReorderSubCourses(ctx, dbgen.ReorderSubCoursesParams{
Ids: ids,
Positions: positions,
})
}

View File

@ -1,44 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/domain"
"context"
)
func (s *Service) CreateCourseCategory(
ctx context.Context,
name string,
) (domain.CourseCategory, error) {
return s.courseStore.CreateCourseCategory(ctx, name)
}
func (s *Service) GetCourseCategoryByID(
ctx context.Context,
id int64,
) (domain.CourseCategory, error) {
return s.courseStore.GetCourseCategoryByID(ctx, id)
}
func (s *Service) GetAllCourseCategories(
ctx context.Context,
limit int32,
offset int32,
) ([]domain.CourseCategory, int64, error) {
return s.courseStore.GetAllCourseCategories(ctx, limit, offset)
}
func (s *Service) UpdateCourseCategory(
ctx context.Context,
id int64,
name *string,
isActive *bool,
) error {
return s.courseStore.UpdateCourseCategory(ctx, id, name, isActive)
}
func (s *Service) DeleteCourseCategory(
ctx context.Context,
id int64,
) error {
return s.courseStore.DeleteCourseCategory(ctx, id)
}

View File

@ -1,52 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/domain"
"context"
)
func (s *Service) CreateCourse(
ctx context.Context,
categoryID int64,
title string,
description *string,
thumbnail *string,
introVideoURL *string,
) (domain.Course, error) {
return s.courseStore.CreateCourse(ctx, categoryID, title, description, thumbnail, introVideoURL)
}
func (s *Service) GetCourseByID(
ctx context.Context,
id int64,
) (domain.Course, error) {
return s.courseStore.GetCourseByID(ctx, id)
}
func (s *Service) GetCoursesByCategory(
ctx context.Context,
categoryID int64,
limit int32,
offset int32,
) ([]domain.Course, int64, error) {
return s.courseStore.GetCoursesByCategory(ctx, categoryID, limit, offset)
}
func (s *Service) UpdateCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
introVideoURL *string,
isActive *bool,
) error {
return s.courseStore.UpdateCourse(ctx, id, title, description, thumbnail, introVideoURL, isActive)
}
func (s *Service) DeleteCourse(
ctx context.Context,
id int64,
) error {
return s.courseStore.DeleteCourse(ctx, id)
}

View File

@ -1,34 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/domain"
"context"
)
func (s *Service) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) {
return s.courseStore.GetFullLearningTree(ctx)
}
func (s *Service) GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) {
return s.courseStore.GetCourseLearningPath(ctx, courseID)
}
func (s *Service) ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error {
return s.courseStore.ReorderCourseCategories(ctx, ids, positions)
}
func (s *Service) ReorderCourses(ctx context.Context, ids []int64, positions []int32) error {
return s.courseStore.ReorderCourses(ctx, ids, positions)
}
func (s *Service) ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error {
return s.courseStore.ReorderSubCourses(ctx, ids, positions)
}
func (s *Service) ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error {
return s.courseStore.ReorderSubCourseVideos(ctx, ids, positions)
}
func (s *Service) ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error {
return s.courseStore.ReorderQuestionSets(ctx, ids, positions)
}

View File

@ -1,73 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/domain"
"context"
)
// --- Prerequisites (admin) ---
func (s *Service) AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error {
if subCourseID == prerequisiteSubCourseID {
return domain.ErrSelfPrerequisite
}
return s.progressionStore.AddSubCoursePrerequisite(ctx, subCourseID, prerequisiteSubCourseID)
}
func (s *Service) RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error {
return s.progressionStore.RemoveSubCoursePrerequisite(ctx, subCourseID, prerequisiteSubCourseID)
}
func (s *Service) GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error) {
return s.progressionStore.GetSubCoursePrerequisites(ctx, subCourseID)
}
func (s *Service) GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error) {
return s.progressionStore.GetSubCourseDependents(ctx, prerequisiteSubCourseID)
}
// --- User progress ---
func (s *Service) CheckSubCourseAccess(ctx context.Context, userID, subCourseID int64) (bool, error) {
unmet, err := s.progressionStore.CountUnmetPrerequisites(ctx, subCourseID, userID)
if err != nil {
return false, err
}
return unmet == 0, nil
}
func (s *Service) StartSubCourse(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) {
accessible, err := s.CheckSubCourseAccess(ctx, userID, subCourseID)
if err != nil {
return domain.UserSubCourseProgress{}, err
}
if !accessible {
return domain.UserSubCourseProgress{}, domain.ErrPrerequisiteNotMet
}
return s.progressionStore.StartSubCourseProgress(ctx, userID, subCourseID)
}
func (s *Service) UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error {
return s.progressionStore.UpdateSubCourseProgress(ctx, userID, subCourseID, percentage)
}
func (s *Service) CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error {
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)
}
func (s *Service) GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error) {
return s.progressionStore.GetUserCourseProgress(ctx, userID, courseID)
}
func (s *Service) GetSubCoursesWithProgress(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error) {
return s.progressionStore.GetSubCoursesWithProgressByCourse(ctx, userID, courseID)
}

View File

@ -10,8 +10,8 @@ import (
type Service struct {
userStore ports.UserStore
courseStore ports.CourseStore
progressionStore ports.ProgressionStore
courseStore interface{}
progressionStore interface{}
notificationSvc *notificationservice.Service
vimeoSvc *vimeoservice.Service
cloudConvertSvc *cloudconvertservice.Service
@ -20,8 +20,8 @@ type Service struct {
func NewService(
userStore ports.UserStore,
courseStore ports.CourseStore,
progressionStore ports.ProgressionStore,
courseStore interface{},
progressionStore interface{},
notificationSvc *notificationservice.Service,
cfg *config.Config,
) *Service {

View File

@ -1,299 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/pkgs/vimeo"
vimeoservice "Yimaru-Backend/internal/services/vimeo"
"context"
"fmt"
"io"
"net/http"
"path"
"time"
)
func (s *Service) CreateSubCourseVideo(
ctx context.Context,
subCourseID int64,
title string,
description *string,
videoURL string,
duration int32,
resolution *string,
instructorID *string,
thumbnail *string,
visibility *string,
displayOrder *int32,
status *string,
) (domain.SubCourseVideo, error) {
// Default to DIRECT provider when no Vimeo info provided
provider := string(domain.VideoHostProviderDirect)
return s.courseStore.CreateSubCourseVideo(
ctx, subCourseID, title, description, videoURL, duration,
resolution, instructorID, thumbnail, visibility, displayOrder, status,
nil, nil, nil, nil, &provider,
)
}
// CreateSubCourseVideoWithVimeo creates a video and uploads it to Vimeo
func (s *Service) CreateSubCourseVideoWithVimeo(
ctx context.Context,
subCourseID int64,
title string,
description *string,
sourceURL string,
fileSize int64,
duration int32,
resolution *string,
instructorID *string,
thumbnail *string,
visibility *string,
displayOrder *int32,
) (domain.SubCourseVideo, error) {
if s.vimeoSvc == nil {
return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured")
}
descStr := ""
if description != nil {
descStr = *description
}
var uploadResult *vimeoservice.UploadResult
var err error
if s.cloudConvertSvc != nil {
httpClient := &http.Client{Timeout: 30 * time.Minute}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if reqErr != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to create download request: %w", reqErr)
}
resp, dlErr := httpClient.Do(req)
if dlErr != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to download source video: %w", dlErr)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return domain.SubCourseVideo{}, fmt.Errorf("failed to download source video: status %d", resp.StatusCode)
}
dlSize := resp.ContentLength
if dlSize <= 0 {
dlSize = fileSize
}
filename := path.Base(sourceURL)
if filename == "" || filename == "." || filename == "/" {
filename = "video.mp4"
}
result, compErr := s.cloudConvertSvc.CompressVideo(ctx, filename, resp.Body, dlSize)
if compErr != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to compress video: %w", compErr)
}
defer result.Data.Close()
uploadResult, err = s.vimeoSvc.UploadVideoFile(ctx, title, descStr, result.Data, result.FileSize)
} else {
uploadResult, err = s.vimeoSvc.CreatePullUpload(ctx, title, descStr, sourceURL, fileSize)
}
if err != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to upload to Vimeo: %w", err)
}
embedURL := vimeo.GenerateEmbedURL(uploadResult.VimeoID, &vimeo.EmbedOptions{
Title: true,
Byline: true,
Portrait: true,
})
embedHTML := vimeo.GenerateIframeEmbed(uploadResult.VimeoID, 640, 360, nil)
provider := string(domain.VideoHostProviderVimeo)
vimeoStatus := "uploading"
status := "DRAFT"
return s.courseStore.CreateSubCourseVideo(
ctx, subCourseID, title, description,
uploadResult.Link,
duration, resolution, instructorID, thumbnail, visibility, displayOrder, &status,
&uploadResult.VimeoID, &embedURL, &embedHTML, &vimeoStatus, &provider,
)
}
func (s *Service) CreateSubCourseVideoWithFileUpload(
ctx context.Context,
subCourseID int64,
title string,
description *string,
filename string,
fileData io.Reader,
fileSize int64,
duration int32,
resolution *string,
instructorID *string,
thumbnail *string,
visibility *string,
displayOrder *int32,
) (domain.SubCourseVideo, error) {
if s.vimeoSvc == nil {
return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured")
}
descStr := ""
if description != nil {
descStr = *description
}
videoReader := fileData
videoSize := fileSize
if s.cloudConvertSvc != nil {
result, err := s.cloudConvertSvc.CompressVideo(ctx, filename, fileData, fileSize)
if err != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to compress video: %w", err)
}
defer result.Data.Close()
videoReader = result.Data
videoSize = result.FileSize
}
uploadResult, err := s.vimeoSvc.UploadVideoFile(ctx, title, descStr, videoReader, videoSize)
if err != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to upload video file to Vimeo: %w", err)
}
embedURL := vimeo.GenerateEmbedURL(uploadResult.VimeoID, &vimeo.EmbedOptions{
Title: true,
Byline: true,
Portrait: true,
})
embedHTML := vimeo.GenerateIframeEmbed(uploadResult.VimeoID, 640, 360, nil)
provider := string(domain.VideoHostProviderVimeo)
vimeoStatus := "uploading"
status := "DRAFT"
return s.courseStore.CreateSubCourseVideo(
ctx, subCourseID, title, description,
uploadResult.Link,
duration, resolution, instructorID, thumbnail, visibility, displayOrder, &status,
&uploadResult.VimeoID, &embedURL, &embedHTML, &vimeoStatus, &provider,
)
}
// CreateSubCourseVideoFromVimeoID creates a video record from an existing Vimeo video
func (s *Service) CreateSubCourseVideoFromVimeoID(
ctx context.Context,
subCourseID int64,
vimeoVideoID string,
title string,
description *string,
displayOrder *int32,
instructorID *string,
) (domain.SubCourseVideo, error) {
if s.vimeoSvc == nil {
return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured")
}
// Fetch video info from Vimeo
info, err := s.vimeoSvc.GetVideoInfo(ctx, vimeoVideoID)
if err != nil {
return domain.SubCourseVideo{}, fmt.Errorf("failed to get Vimeo video info: %w", err)
}
// Use Vimeo data
embedHTML := vimeo.GenerateIframeEmbed(vimeoVideoID, 640, 360, nil)
provider := string(domain.VideoHostProviderVimeo)
vimeoStatus := info.TranscodeStatus
if vimeoStatus == "" {
vimeoStatus = "available"
}
status := "DRAFT"
duration := int32(info.Duration)
resolution := fmt.Sprintf("%dx%d", info.Width, info.Height)
thumbnail := info.ThumbnailURL
return s.courseStore.CreateSubCourseVideo(
ctx, subCourseID, title, description,
info.Link, duration, &resolution, instructorID, &thumbnail, nil, displayOrder, &status,
&vimeoVideoID, &info.EmbedURL, &embedHTML, &vimeoStatus, &provider,
)
}
func (s *Service) GetSubCourseVideoByID(
ctx context.Context,
id int64,
) (domain.SubCourseVideo, error) {
return s.courseStore.GetSubCourseVideoByID(ctx, id)
}
func (s *Service) GetVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, int64, error) {
return s.courseStore.GetVideosBySubCourse(ctx, subCourseID)
}
func (s *Service) GetPublishedVideosBySubCourse(
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, error) {
return s.courseStore.GetPublishedVideosBySubCourse(ctx, subCourseID)
}
func (s *Service) GetFirstIncompletePreviousVideo(
ctx context.Context,
userID int64,
videoID int64,
) (*domain.VideoAccessBlock, error) {
return s.courseStore.GetFirstIncompletePreviousVideo(ctx, userID, videoID)
}
func (s *Service) MarkVideoCompleted(
ctx context.Context,
userID int64,
videoID int64,
) error {
return s.courseStore.MarkVideoCompleted(ctx, userID, videoID)
}
func (s *Service) PublishSubCourseVideo(
ctx context.Context,
videoID int64,
) error {
return s.courseStore.PublishSubCourseVideo(ctx, videoID)
}
func (s *Service) UpdateSubCourseVideo(
ctx context.Context,
id int64,
title *string,
description *string,
videoURL *string,
duration *int32,
resolution *string,
visibility *string,
thumbnail *string,
displayOrder *int32,
status *string,
) error {
return s.courseStore.UpdateSubCourseVideo(ctx, id, title, description, videoURL, duration, resolution, visibility, thumbnail, displayOrder, status)
}
func (s *Service) ArchiveSubCourseVideo(
ctx context.Context,
id int64,
) error {
return s.courseStore.ArchiveSubCourseVideo(ctx, id)
}
func (s *Service) DeleteSubCourseVideo(
ctx context.Context,
id int64,
) error {
return s.courseStore.DeleteSubCourseVideo(ctx, id)
}

View File

@ -1,74 +0,0 @@
package course_management
import (
"Yimaru-Backend/internal/domain"
"context"
)
func (s *Service) CreateSubCourse(
ctx context.Context,
courseID int64,
title string,
description *string,
thumbnail *string,
displayOrder *int32,
level string,
subLevel string,
) (domain.SubCourse, error) {
return s.courseStore.CreateSubCourse(ctx, courseID, title, description, thumbnail, displayOrder, level, subLevel)
}
func (s *Service) GetSubCourseByID(
ctx context.Context,
id int64,
) (domain.SubCourse, error) {
return s.courseStore.GetSubCourseByID(ctx, id)
}
func (s *Service) GetSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, int64, error) {
return s.courseStore.GetSubCoursesByCourse(ctx, courseID)
}
func (s *Service) ListSubCoursesByCourse(
ctx context.Context,
courseID int64,
) ([]domain.SubCourse, error) {
return s.courseStore.ListSubCoursesByCourse(ctx, courseID)
}
func (s *Service) ListActiveSubCourses(
ctx context.Context,
) ([]domain.SubCourse, error) {
return s.courseStore.ListActiveSubCourses(ctx)
}
func (s *Service) UpdateSubCourse(
ctx context.Context,
id int64,
title *string,
description *string,
thumbnail *string,
displayOrder *int32,
level *string,
subLevel *string,
isActive *bool,
) error {
return s.courseStore.UpdateSubCourse(ctx, id, title, description, thumbnail, displayOrder, level, subLevel, isActive)
}
func (s *Service) DeactivateSubCourse(
ctx context.Context,
id int64,
) error {
return s.courseStore.DeactivateSubCourse(ctx, id)
}
func (s *Service) DeleteSubCourse(
ctx context.Context,
id int64,
) (domain.SubCourse, error) {
return s.courseStore.DeleteSubCourse(ctx, id)
}

File diff suppressed because it is too large Load Diff

View File

@ -313,7 +313,7 @@ func normalizeAndValidateMediaContentType(mediaType, contentType, fileName strin
// @Summary Upload an audio file
// @Tags files
// @Accept multipart/form-data
// @Param file formance file true "Audio file (mp3, wav, ogg, m4a, aac, webm)"
// @Param file formData file true "Audio file (mp3, wav, ogg, m4a, aac, webm)"
// @Success 200 {object} domain.Response
// @Router /api/v1/files/audio [post]
func (h *Handler) UploadAudio(c *fiber.Ctx) error {

View File

@ -0,0 +1,382 @@
package handlers
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5/pgtype"
)
type createCourseSubCategoryReq struct {
CategoryID int64 `json:"category_id"`
Name string `json:"name"`
Description *string `json:"description"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createLevelReq struct {
CourseID int64 `json:"course_id"`
CEFRLevel string `json:"cefr_level"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createModuleReq struct {
LevelID int64 `json:"level_id"`
Title string `json:"title"`
Description *string `json:"description"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createSubModuleReq struct {
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Description *string `json:"description"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createSubModuleVideoReq struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description *string `json:"description"`
VideoURL string `json:"video_url"`
Duration *int32 `json:"duration"`
Resolution *string `json:"resolution"`
Visibility *string `json:"visibility"`
InstructorID *string `json:"instructor_id"`
Thumbnail *string `json:"thumbnail"`
DisplayOrder *int32 `json:"display_order"`
Status *string `json:"status"`
}
type attachSubModuleLessonReq struct {
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoURL *string `json:"intro_video_url"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createSubModulePracticeReq struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description *string `json:"description"`
Thumbnail *string `json:"thumbnail"`
IntroVideoURL *string `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
func toText(v *string) pgtype.Text {
if v == nil {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: *v, Valid: true}
}
func toInt4(v *int32) pgtype.Int4 {
if v == nil {
return pgtype.Int4{Valid: false}
}
return pgtype.Int4{Int32: *v, Valid: true}
}
func boolOrNil(v *bool) interface{} {
if v == nil {
return nil
}
return *v
}
func intOrNil(v *int32) interface{} {
if v == nil {
return nil
}
return *v
}
// UnifiedHierarchy godoc
// @Summary Get unified course hierarchy
// @Description Returns full hierarchy: category -> sub-category -> course
// @Tags course-management
// @Produce json
// @Success 200 {object} domain.Response
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/hierarchy [get]
func (h *Handler) UnifiedHierarchy(c *fiber.Ctx) error {
rows, err := h.analyticsDB.GetCoursesWithHierarchy(c.Context())
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load hierarchy", Error: err.Error()})
}
return c.JSON(domain.Response{Message: "Unified hierarchy retrieved successfully", Data: rows})
}
// UnifiedHierarchyByCourse godoc
// @Summary Get hierarchy for a course
// @Description Returns hierarchy nodes for one course including levels/modules/sub-modules
// @Tags course-management
// @Produce json
// @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/course-management/courses/{courseId}/hierarchy [get]
func (h *Handler) UnifiedHierarchyByCourse(c *fiber.Ctx) 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()})
}
rows, err := h.analyticsDB.GetFullHierarchyByCourseID(c.Context(), courseID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course hierarchy", Error: err.Error()})
}
return c.JSON(domain.Response{Message: "Course hierarchy retrieved successfully", Data: rows})
}
// CreateCourseSubCategory godoc
// @Summary Create course sub-category
// @Description Creates a sub-category under a course category
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createCourseSubCategoryReq true "Create sub-category payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-categories [post]
func (h *Handler) CreateCourseSubCategory(c *fiber.Ctx) error {
var req createCourseSubCategoryReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.CategoryID <= 0 || strings.TrimSpace(req.Name) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "category_id and name are required"})
}
created, err := h.analyticsDB.CreateCourseSubCategory(c.Context(), dbgen.CreateCourseSubCategoryParams{
CategoryID: req.CategoryID,
Name: req.Name,
Description: toText(req.Description),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-category", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Course sub-category created", Data: created})
}
// CreateLevel godoc
// @Summary Create level
// @Description Creates a CEFR level under a course
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createLevelReq true "Create level payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/levels [post]
func (h *Handler) CreateLevel(c *fiber.Ctx) error {
var req createLevelReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
req.CEFRLevel = strings.ToUpper(strings.TrimSpace(req.CEFRLevel))
validCEFR := map[string]bool{"A1": true, "A2": true, "A3": true, "B1": true, "B2": true, "B3": true, "C1": true, "C2": true, "C3": true}
if req.CourseID <= 0 || !validCEFR[req.CEFRLevel] {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "course_id and valid cefr_level are required"})
}
created, err := h.analyticsDB.CreateLevel(c.Context(), dbgen.CreateLevelParams{
CourseID: req.CourseID,
CefrLevel: req.CEFRLevel,
Column3: intOrNil(req.DisplayOrder),
Column4: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create level", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Level created", Data: created})
}
// CreateModule godoc
// @Summary Create module
// @Description Creates a module under a level
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createModuleReq true "Create module payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/modules [post]
func (h *Handler) CreateModule(c *fiber.Ctx) error {
var req createModuleReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.LevelID <= 0 || strings.TrimSpace(req.Title) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "level_id and title are required"})
}
created, err := h.analyticsDB.CreateModule(c.Context(), dbgen.CreateModuleParams{
LevelID: req.LevelID,
Title: req.Title,
Description: toText(req.Description),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create module", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Module created", Data: created})
}
// CreateSubModule godoc
// @Summary Create sub-module
// @Description Creates a sub-module under a module
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createSubModuleReq true "Create sub-module payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-modules [post]
func (h *Handler) CreateSubModule(c *fiber.Ctx) error {
var req createSubModuleReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.ModuleID <= 0 || strings.TrimSpace(req.Title) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "module_id and title are required"})
}
created, err := h.analyticsDB.CreateSubModule(c.Context(), dbgen.CreateSubModuleParams{
ModuleID: req.ModuleID,
Title: req.Title,
Description: toText(req.Description),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module created", Data: created})
}
// CreateSubModuleVideo godoc
// @Summary Create sub-module video
// @Description Creates a video under a sub-module
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createSubModuleVideoReq true "Create sub-module video payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-module-videos [post]
func (h *Handler) CreateSubModuleVideo(c *fiber.Ctx) error {
var req createSubModuleVideoReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.SubModuleID <= 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.VideoURL) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id, title and video_url are required"})
}
created, err := h.analyticsDB.CreateSubModuleVideo(c.Context(), dbgen.CreateSubModuleVideoParams{
SubModuleID: req.SubModuleID,
Title: req.Title,
Description: toText(req.Description),
VideoUrl: req.VideoURL,
Duration: toInt4(req.Duration),
Resolution: toText(req.Resolution),
Column7: nil,
Visibility: toText(req.Visibility),
InstructorID: toText(req.InstructorID),
Thumbnail: toText(req.Thumbnail),
Column12: intOrNil(req.DisplayOrder),
Column13: req.Status,
VimeoID: pgtype.Text{Valid: false},
VimeoEmbedUrl: pgtype.Text{Valid: false},
VimeoPlayerHtml: pgtype.Text{Valid: false},
VimeoStatus: pgtype.Text{Valid: false},
Column18: nil,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module video", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module video created", Data: created})
}
// AttachSubModuleLesson godoc
// @Summary Attach lesson to sub-module
// @Description Links a question set lesson to a sub-module
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body attachSubModuleLessonReq true "Attach lesson payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-module-lessons [post]
func (h *Handler) AttachSubModuleLesson(c *fiber.Ctx) error {
var req attachSubModuleLessonReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.SubModuleID <= 0 || req.QuestionSetID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id and question_set_id are required"})
}
attached, err := h.analyticsDB.AttachQuestionSetLessonToSubModule(c.Context(), dbgen.AttachQuestionSetLessonToSubModuleParams{
SubModuleID: req.SubModuleID,
QuestionSetID: req.QuestionSetID,
IntroVideoUrl: toText(req.IntroVideoURL),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to attach lesson", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Lesson attached to sub-module", Data: attached})
}
// CreateSubModulePractice godoc
// @Summary Create practice under sub-module
// @Description Creates a sub-module practice with metadata and linked question set
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createSubModulePracticeReq true "Create practice payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-module-practices [post]
func (h *Handler) CreateSubModulePractice(c *fiber.Ctx) error {
var req createSubModulePracticeReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.SubModuleID <= 0 || req.QuestionSetID <= 0 || strings.TrimSpace(req.Title) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id, title and question_set_id are required"})
}
created, err := h.analyticsDB.CreateSubModulePractice(c.Context(), dbgen.CreateSubModulePracticeParams{
SubModuleID: req.SubModuleID,
Title: req.Title,
Description: toText(req.Description),
Thumbnail: toText(req.Thumbnail),
IntroVideoUrl: toText(req.IntroVideoURL),
QuestionSetID: req.QuestionSetID,
Column7: intOrNil(req.DisplayOrder),
Column8: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create practice", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Practice created", Data: created})
}

View File

@ -78,7 +78,7 @@ func resolveSeedDir(seedDir string) (string, error) {
// ResetAndReseedDatabase godoc
// @Summary Reset and reseed database
// @Description Dangerous operation: clears and reseeds only course_categories, courses, and sub_courses from seed SQL files.
// @Description Truncates course_categories, courses, and sub_courses. If seed SQL contains INSERTs for those tables (e.g. 007_course_management_seed.sql), they are replayed; otherwise tables are left empty after truncate.
// @Tags internal
// @Accept json
// @Produce json
@ -154,26 +154,32 @@ func (h *Handler) ResetAndReseedDatabase(c *fiber.Ctx) error {
}
}
missing := 0
for _, tableName := range tableNames {
if _, ok := statements[tableName]; !ok {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Missing required seed statement",
Error: fmt.Sprintf("could not find INSERT INTO %s in seed files", tableName),
})
missing++
}
}
if missing != 0 && missing != len(tableNames) {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Incomplete course seed SQL",
Error: "seed files must define INSERT for all of course_categories, courses, and sub_courses, or none of them (truncate-only)",
})
}
var sqlBuilder strings.Builder
sqlBuilder.WriteString("BEGIN;\n")
sqlBuilder.WriteString("TRUNCATE TABLE sub_courses, courses, course_categories RESTART IDENTITY CASCADE;\n")
for _, tableName := range tableNames {
sqlBuilder.WriteString("\n-- ")
sqlBuilder.WriteString(tableName)
sqlBuilder.WriteString(" from ")
sqlBuilder.WriteString(statementSource[tableName])
sqlBuilder.WriteString("\n")
sqlBuilder.WriteString(statements[tableName])
sqlBuilder.WriteString("\n")
if missing == 0 {
for _, tableName := range tableNames {
sqlBuilder.WriteString("\n-- ")
sqlBuilder.WriteString(tableName)
sqlBuilder.WriteString(" from ")
sqlBuilder.WriteString(statementSource[tableName])
sqlBuilder.WriteString("\n")
sqlBuilder.WriteString(statements[tableName])
sqlBuilder.WriteString("\n")
}
}
sqlBuilder.WriteString("COMMIT;")
@ -184,12 +190,18 @@ func (h *Handler) ResetAndReseedDatabase(c *fiber.Ctx) error {
})
}
msg := "Course management hierarchy reset and reseed completed successfully"
if missing == len(tableNames) {
msg = "Course management hierarchy truncated successfully (no INSERT seed configured; tables empty)"
}
return c.JSON(domain.Response{
Message: "Course management hierarchy reset and reseed completed successfully",
Message: msg,
Data: map[string]interface{}{
"seed_dir": seedDir,
"tables": tableNames,
"sources": statementSource,
"seed_dir": seedDir,
"tables": tableNames,
"sources": statementSource,
"reseeded": missing == 0,
"truncate_only": missing == len(tableNames),
},
Success: true,
StatusCode: fiber.StatusOK,

View File

@ -1,541 +0,0 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"errors"
"math"
"strconv"
"time"
"github.com/gofiber/fiber/v2"
)
// --- Request / Response types ---
type addPrerequisiteReq struct {
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id" validate:"required"`
}
type prerequisiteRes struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
PrerequisiteTitle string `json:"prerequisite_title"`
PrerequisiteLevel string `json:"prerequisite_level"`
PrerequisiteDisplayOrder int32 `json:"prerequisite_display_order"`
}
type dependentRes struct {
ID int64 `json:"id"`
SubCourseID int64 `json:"sub_course_id"`
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
DependentTitle string `json:"dependent_title"`
DependentLevel string `json:"dependent_level"`
}
type updateProgressReq struct {
ProgressPercentage int16 `json:"progress_percentage" validate:"required,min=0,max=100"`
}
type subCourseProgressRes struct {
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Thumbnail *string `json:"thumbnail,omitempty"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
ProgressStatus string `json:"progress_status"`
ProgressPercentage int16 `json:"progress_percentage"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
IsLocked bool `json:"is_locked"`
}
type userProgressRes struct {
SubCourseID int64 `json:"sub_course_id"`
SubCourseTitle string `json:"sub_course_title"`
SubCourseLevel string `json:"sub_course_level"`
Status string `json:"status"`
ProgressPercentage int16 `json:"progress_percentage"`
StartedAt *time.Time `json:"started_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) ---
// AddSubCoursePrerequisite godoc
// @Summary Add prerequisite to sub-course
// @Description Link a prerequisite sub-course that must be completed before accessing this sub-course
// @Tags progression
// @Accept json
// @Produce json
// @Param id path int true "Sub-course ID"
// @Param body body addPrerequisiteReq true "Prerequisite sub-course ID"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-courses/{id}/prerequisites [post]
func (h *Handler) AddSubCoursePrerequisite(c *fiber.Ctx) error {
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
var req addPrerequisiteReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if err := h.courseMgmtSvc.AddSubCoursePrerequisite(c.Context(), subCourseID, req.PrerequisiteSubCourseID); err != nil {
if errors.Is(err, domain.ErrSelfPrerequisite) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid prerequisite",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to add prerequisite",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Prerequisite added successfully",
})
}
// GetSubCoursePrerequisites godoc
// @Summary Get sub-course prerequisites
// @Description Returns all prerequisites for a sub-course
// @Tags progression
// @Produce json
// @Param id path int true "Sub-course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-courses/{id}/prerequisites [get]
func (h *Handler) GetSubCoursePrerequisites(c *fiber.Ctx) error {
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
prerequisites, err := h.courseMgmtSvc.GetSubCoursePrerequisites(c.Context(), subCourseID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get prerequisites",
Error: err.Error(),
})
}
var res []prerequisiteRes
for _, p := range prerequisites {
res = append(res, prerequisiteRes{
ID: p.ID,
SubCourseID: p.SubCourseID,
PrerequisiteSubCourseID: p.PrerequisiteSubCourseID,
PrerequisiteTitle: p.PrerequisiteTitle,
PrerequisiteLevel: p.PrerequisiteLevel,
PrerequisiteDisplayOrder: p.PrerequisiteDisplayOrder,
})
}
return c.JSON(domain.Response{
Message: "Prerequisites retrieved successfully",
Data: res,
})
}
// RemoveSubCoursePrerequisite godoc
// @Summary Remove prerequisite from sub-course
// @Description Unlink a prerequisite from a sub-course
// @Tags progression
// @Produce json
// @Param id path int true "Sub-course ID"
// @Param prerequisiteId path int true "Prerequisite sub-course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-courses/{id}/prerequisites/{prerequisiteId} [delete]
func (h *Handler) RemoveSubCoursePrerequisite(c *fiber.Ctx) error {
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
prerequisiteID, err := strconv.ParseInt(c.Params("prerequisiteId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid prerequisite ID",
Error: err.Error(),
})
}
if err := h.courseMgmtSvc.RemoveSubCoursePrerequisite(c.Context(), subCourseID, prerequisiteID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to remove prerequisite",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Prerequisite removed successfully",
})
}
// --- User Progress Handlers ---
// StartSubCourse godoc
// @Summary Start a sub-course
// @Description Mark a sub-course as started for the authenticated user (checks prerequisites)
// @Tags progression
// @Produce json
// @Param id path int true "Sub-course ID"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/progress/sub-courses/{id}/start [post]
func (h *Handler) StartSubCourse(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
progress, err := h.courseMgmtSvc.StartSubCourse(c.Context(), userID, subCourseID)
if err != nil {
if errors.Is(err, domain.ErrPrerequisiteNotMet) {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Cannot start sub-course",
Error: "Prerequisites not completed",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to start sub-course",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Sub-course started",
Data: userProgressRes{
SubCourseID: progress.SubCourseID,
Status: string(progress.Status),
ProgressPercentage: progress.ProgressPercentage,
StartedAt: progress.StartedAt,
},
})
}
// UpdateSubCourseProgress godoc
// @Summary Update sub-course progress
// @Description Update the progress percentage for a sub-course
// @Tags progression
// @Accept json
// @Produce json
// @Param id path int true "Sub-course ID"
// @Param body body updateProgressReq true "Progress update"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/progress/sub-courses/{id} [put]
func (h *Handler) UpdateSubCourseProgress(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
var req updateProgressReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if err := h.courseMgmtSvc.UpdateSubCourseProgress(c.Context(), userID, subCourseID, req.ProgressPercentage); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update progress",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Progress updated successfully",
})
}
// CompleteSubCourse godoc
// @Summary Complete a sub-course
// @Description Mark a sub-course as completed for the authenticated user
// @Tags progression
// @Produce json
// @Param id path int true "Sub-course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/progress/sub-courses/{id}/complete [post]
func (h *Handler) CompleteSubCourse(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
if err := h.courseMgmtSvc.CompleteSubCourse(c.Context(), userID, subCourseID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to complete sub-course",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Sub-course completed",
})
}
// CheckSubCourseAccess godoc
// @Summary Check sub-course access
// @Description Check if the authenticated user has completed all prerequisites for a sub-course
// @Tags progression
// @Produce json
// @Param id path int true "Sub-course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/progress/sub-courses/{id}/access [get]
func (h *Handler) CheckSubCourseAccess(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
accessible, err := h.courseMgmtSvc.CheckSubCourseAccess(c.Context(), userID, subCourseID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to check access",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Access check completed",
Data: fiber.Map{
"accessible": accessible,
},
})
}
// GetUserCourseProgress godoc
// @Summary Get user's course progress
// @Description Returns the authenticated user's progress for all sub-courses in a course, including lock status
// @Tags progression
// @Produce json
// @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/progress/courses/{courseId} [get]
func (h *Handler) GetUserCourseProgress(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
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(), userID, courseID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get course progress",
Error: err.Error(),
})
}
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: "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

@ -1350,19 +1350,6 @@ 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

@ -0,0 +1,85 @@
package handlers
import (
"bytes"
"io"
"net/http"
"os"
"path/filepath"
"Yimaru-Backend/internal/domain"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"go.uber.org/zap"
)
func (h *Handler) processAndSaveThumbnail(c *fiber.Ctx, subDir string) (string, error) {
fileHeader, err := c.FormFile("file")
if err != nil {
return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Image file is required", Error: err.Error()})
}
if fileHeader.Size > 10*1024*1024 {
return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "File too large", Error: "Thumbnail image must be <= 10MB"})
}
fh, err := fileHeader.Open()
if err != nil {
return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to read file", Error: err.Error()})
}
defer fh.Close()
head := make([]byte, 512)
n, _ := fh.Read(head)
contentType := http.DetectContentType(head[:n])
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid file type", Error: "Only jpg, png and webp images are allowed"})
}
rest, err := io.ReadAll(fh)
if err != nil {
return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to read file", Error: err.Error()})
}
data := append(head[:n], rest...)
if h.cloudConvertSvc != nil {
optimized, optErr := h.cloudConvertSvc.OptimizeImage(c.Context(), fileHeader.Filename, bytes.NewReader(data), int64(len(data)), 1200, 80)
if optErr != nil {
h.mongoLoggerSvc.Warn("CloudConvert thumbnail optimization failed, using original", zap.Error(optErr))
} else {
optimizedData, readErr := io.ReadAll(optimized.Data)
optimized.Data.Close()
if readErr == nil {
data = optimizedData
contentType = "image/webp"
}
}
}
if h.minioSvc != nil {
result, uploadErr := h.minioSvc.Upload(c.Context(), subDir, fileHeader.Filename, bytes.NewReader(data), int64(len(data)), contentType)
if uploadErr != nil {
return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to upload file to storage", Error: uploadErr.Error()})
}
return "minio://" + result.ObjectKey, nil
}
ext := ".jpg"
if contentType == "image/png" {
ext = ".png"
}
if contentType == "image/webp" {
ext = ".webp"
}
dir := filepath.Join(".", "static", subDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create storage directory", Error: err.Error()})
}
filename := uuid.New().String() + ext
fullpath := filepath.Join(dir, filename)
if err := os.WriteFile(fullpath, data, 0o644); err != nil {
return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to save file", Error: err.Error()})
}
return "/static/" + subDir + "/" + filename, nil
}

View File

@ -78,60 +78,16 @@ func (a *App) initAppRoutes() {
groupV1.Get("/assessment/questions", h.ListAssessmentQuestions)
groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID)
// Reorder (drag-and-drop support)
// Keep static reorder routes before dynamic `/:id` routes to avoid route collisions
// (e.g., `/courses/reorder` being parsed as `/courses/:id`).
groupV1.Put("/course-management/categories/reorder", a.authMiddleware, a.RequirePermission("course_categories.reorder"), h.ReorderCourseCategories)
groupV1.Put("/course-management/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCourses)
groupV1.Put("/course-management/sub-courses/reorder", a.authMiddleware, a.RequirePermission("subcourses.reorder"), h.ReorderSubCourses)
groupV1.Put("/course-management/videos/reorder", a.authMiddleware, a.RequirePermission("videos.reorder"), h.ReorderSubCourseVideos)
groupV1.Put("/course-management/practices/reorder", a.authMiddleware, a.RequirePermission("practices.reorder"), h.ReorderPractices)
// Course Categories
groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory)
groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.list"), h.GetAllCourseCategories)
groupV1.Get("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.get"), h.GetCourseCategoryByID)
groupV1.Put("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.update"), h.UpdateCourseCategory)
groupV1.Delete("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.delete"), h.DeleteCourseCategory)
// Courses
groupV1.Post("/course-management/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse)
groupV1.Get("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.get"), h.GetCourseByID)
groupV1.Get("/course-management/categories/:categoryId/courses", a.authMiddleware, a.RequirePermission("courses.list_by_category"), h.GetCoursesByCategory)
groupV1.Put("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse)
groupV1.Post("/course-management/courses/:id/thumbnail", a.authMiddleware, a.RequirePermission("courses.upload_thumbnail"), h.UploadCourseThumbnail)
groupV1.Delete("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse)
// Sub-courses
groupV1.Post("/course-management/sub-courses", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubCourse)
groupV1.Get("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.get"), h.GetSubCourseByID)
groupV1.Get("/course-management/courses/:courseId/sub-courses", a.authMiddleware, a.RequirePermission("subcourses.list_by_course"), h.GetSubCoursesByCourse)
groupV1.Get("/course-management/courses/:courseId/sub-courses/list", a.authMiddleware, a.RequirePermission("subcourses.list_by_course_list"), h.ListSubCoursesByCourse)
groupV1.Get("/course-management/sub-courses/active", a.authMiddleware, a.RequirePermission("subcourses.list_active"), h.ListActiveSubCourses)
groupV1.Patch("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateSubCourse)
groupV1.Post("/course-management/sub-courses/:id/thumbnail", a.authMiddleware, a.RequirePermission("subcourses.upload_thumbnail"), h.UploadSubCourseThumbnail)
groupV1.Put("/course-management/sub-courses/:id/deactivate", a.authMiddleware, a.RequirePermission("subcourses.deactivate"), h.DeactivateSubCourse)
groupV1.Delete("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.delete"), h.DeleteSubCourse)
// Sub-course Videos
groupV1.Post("/course-management/videos", a.authMiddleware, a.RequirePermission("videos.create"), h.CreateSubCourseVideo)
groupV1.Post("/course-management/videos/vimeo", a.authMiddleware, a.RequirePermission("videos.create_vimeo"), h.CreateSubCourseVideoWithVimeo)
groupV1.Post("/course-management/videos/upload", a.authMiddleware, a.RequirePermission("videos.upload"), h.UploadSubCourseVideo)
groupV1.Post("/course-management/videos/vimeo/import", a.authMiddleware, a.RequirePermission("videos.import_vimeo"), h.CreateSubCourseVideoFromVimeoID)
groupV1.Get("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.get"), h.GetSubCourseVideoByID)
groupV1.Get("/course-management/sub-courses/:subCourseId/videos", a.authMiddleware, a.RequirePermission("videos.list_by_subcourse"), h.GetVideosBySubCourse)
groupV1.Get("/course-management/sub-courses/:subCourseId/videos/published", a.authMiddleware, a.RequirePermission("videos.list_published"), h.GetPublishedVideosBySubCourse)
groupV1.Put("/course-management/videos/:id/publish", a.authMiddleware, a.RequirePermission("videos.publish"), h.PublishSubCourseVideo)
groupV1.Put("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.update"), h.UpdateSubCourseVideo)
groupV1.Delete("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.delete"), h.DeleteSubCourseVideo)
// Learning Tree
groupV1.Get("/course-management/learning-tree", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetFullLearningTree)
groupV1.Get("/course-management/courses/:courseId/learning-path", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetCourseLearningPath)
groupV1.Get("/course-management/human-language/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetHumanLanguageHierarchy)
groupV1.Get("/course-management/human-language/courses/:courseId/lessons", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetHumanLanguageLessonsByCourse)
groupV1.Post("/course-management/human-language/lessons", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateHumanLanguageLesson)
groupV1.Patch("/course-management/human-language/lessons/:id", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateHumanLanguageLesson)
// Unified Course Management (single hierarchy model)
groupV1.Get("/course-management/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchy)
groupV1.Get("/course-management/courses/:courseId/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchyByCourse)
groupV1.Post("/course-management/sub-categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseSubCategory)
groupV1.Post("/course-management/levels", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateLevel)
groupV1.Post("/course-management/modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateModule)
groupV1.Post("/course-management/sub-modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubModule)
groupV1.Post("/course-management/sub-module-videos", a.authMiddleware, a.RequirePermission("videos.create"), h.CreateSubModuleVideo)
groupV1.Post("/course-management/sub-module-lessons", a.authMiddleware, a.RequirePermission("question_sets.update"), h.AttachSubModuleLesson)
groupV1.Post("/course-management/sub-module-practices", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateSubModulePractice)
// Questions
groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion)
@ -145,7 +101,6 @@ func (a *App) initAppRoutes() {
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
groupV1.Get("/question-sets/by-owner", a.authMiddleware, a.RequirePermission("question_sets.list_by_owner"), h.GetQuestionSetsByOwner)
groupV1.Get("/question-sets/sub-courses/:subCourseId/entry-assessment", a.authMiddleware, a.RequirePermission("question_sets.list_by_owner"), h.GetSubCourseEntryAssessmentSet)
groupV1.Get("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetQuestionSetByID)
groupV1.Put("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateQuestionSet)
groupV1.Delete("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteQuestionSet)
@ -336,21 +291,7 @@ func (a *App) initAppRoutes() {
teamGroup.Delete("/members/:id", a.authMiddleware, a.RequirePermission("team.members.delete"), h.DeleteTeamMember)
teamGroup.Post("/members/:id/change-password", a.authMiddleware, a.RequirePermission("team.members.change_password"), h.ChangeTeamMemberPassword)
// Sub-course Prerequisites
groupV1.Post("/course-management/sub-courses/:id/prerequisites", a.authMiddleware, a.RequirePermission("subcourse_prerequisites.add"), h.AddSubCoursePrerequisite)
groupV1.Get("/course-management/sub-courses/:id/prerequisites", a.authMiddleware, a.RequirePermission("subcourse_prerequisites.list"), h.GetSubCoursePrerequisites)
groupV1.Delete("/course-management/sub-courses/:id/prerequisites/:prerequisiteId", a.authMiddleware, a.RequirePermission("subcourse_prerequisites.remove"), h.RemoveSubCoursePrerequisite)
// User Progression
groupV1.Post("/progress/sub-courses/:id/start", a.authMiddleware, a.RequirePermission("progress.start"), h.StartSubCourse)
groupV1.Put("/progress/sub-courses/:id", a.authMiddleware, a.RequirePermission("progress.update"), h.UpdateSubCourseProgress)
groupV1.Post("/progress/sub-courses/:id/complete", a.authMiddleware, a.RequirePermission("progress.complete"), h.CompleteSubCourse)
groupV1.Post("/progress/videos/:id/complete", a.authMiddleware, a.RequirePermission("progress.update"), h.CompleteSubCourseVideo)
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)
// Legacy sub-course prerequisite/progression routes removed after hierarchy cutover.
// Ratings
groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating)

View File

@ -66,10 +66,6 @@ seed_data:
sleep 1; \
done
@for file in db/data/*.sql; do \
if [ "$$(basename $$file)" = "007_course_management_seed.sql" ]; then \
echo "Skipping $$file (course management seed disabled)"; \
continue; \
fi; \
echo "Seeding $$file..."; \
cat $$file | docker exec -i yimaru-backend-postgres-1 psql -U root -d gh; \
done