diff --git a/db/data/007_course_management_seed.sql b/db/data/007_course_management_seed.sql new file mode 100644 index 0000000..b14fdbf --- /dev/null +++ b/db/data/007_course_management_seed.sql @@ -0,0 +1,316 @@ +-- ====================================================== +-- 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, is_active) VALUES +-- Flutter sub-courses (course 8) — IDs 18-21 +(18, 8, 'Dart Language Basics', 'Learn Dart programming language fundamentals', NULL, 1, 'BEGINNER', TRUE), +(19, 8, 'Flutter UI Widgets', 'Build beautiful UIs with Flutter widgets', NULL, 2, 'BEGINNER', TRUE), +(20, 8, 'State Management', 'Manage app state with Provider and Riverpod', NULL, 3, 'INTERMEDIATE', TRUE), +(21, 8, 'Flutter Networking & APIs', 'HTTP requests, REST APIs, and data persistence', NULL, 4, 'ADVANCED', TRUE), + +-- React Native sub-courses (course 9) — IDs 22-24 +(22, 9, 'React Native Setup', 'Environment setup and first app', NULL, 1, 'BEGINNER', TRUE), +(23, 9, 'Navigation & Routing', 'React Navigation and screen management', NULL, 2, 'INTERMEDIATE', TRUE), +(24, 9, 'Native Modules', 'Bridge native code with React Native', NULL, 3, 'ADVANCED', TRUE), + +-- Docker & Kubernetes sub-courses (course 10) — IDs 25-27 +(25, 10, 'Docker Fundamentals', 'Containers, images, and Dockerfiles', NULL, 1, 'BEGINNER', TRUE), +(26, 10, 'Docker Compose', 'Multi-container applications', NULL, 2, 'INTERMEDIATE', TRUE), +(27, 10, 'Kubernetes Basics', 'Pods, services, and deployments', NULL, 3, 'ADVANCED', TRUE), + +-- CI/CD sub-courses (course 11) — IDs 28-29 +(28, 11, 'Git Workflows', 'Branching strategies and pull requests', NULL, 1, 'BEGINNER', TRUE), +(29, 11, 'GitHub Actions', 'Automate workflows with GitHub Actions', NULL, 2, 'INTERMEDIATE', TRUE), + +-- Cybersecurity sub-courses (course 12) — IDs 30-31 +(30, 12, 'Network Security Basics', 'Firewalls, VPNs, and network protocols', NULL, 1, 'BEGINNER', TRUE), +(31, 12, 'Penetration Testing', 'Tools and techniques for pen testing', NULL, 2, 'ADVANCED', 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; + +-- 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; + +-- ====================================================== +-- User Sub-course Progress +-- Simulate realistic student progress for admin panel +-- ====================================================== +INSERT INTO user_sub_course_progress (user_id, sub_course_id, status, progress_percentage, started_at, completed_at) VALUES +-- Student 10 (Demo Student): working through Python course +(10, 1, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '30 days', CURRENT_TIMESTAMP - INTERVAL '20 days'), +(10, 2, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '20 days', CURRENT_TIMESTAMP - INTERVAL '12 days'), +(10, 3, 'IN_PROGRESS', 65, CURRENT_TIMESTAMP - INTERVAL '12 days', NULL), + +-- Student 10: started Flutter +(10, 18, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '15 days', CURRENT_TIMESTAMP - INTERVAL '8 days'), +(10, 19, 'IN_PROGRESS', 40, CURRENT_TIMESTAMP - INTERVAL '8 days', NULL), + +-- Student 11 (Abebe): completed Python, started JavaScript +(11, 1, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '45 days', CURRENT_TIMESTAMP - INTERVAL '35 days'), +(11, 2, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '35 days', CURRENT_TIMESTAMP - INTERVAL '25 days'), +(11, 3, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '25 days', CURRENT_TIMESTAMP - INTERVAL '15 days'), +(11, 4, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '15 days', CURRENT_TIMESTAMP - INTERVAL '5 days'), +(11, 5, 'IN_PROGRESS', 30, CURRENT_TIMESTAMP - INTERVAL '5 days', NULL), +(11, 6, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '20 days', CURRENT_TIMESTAMP - INTERVAL '10 days'), +(11, 7, 'IN_PROGRESS', 50, CURRENT_TIMESTAMP - INTERVAL '10 days', NULL), + +-- Student 11: Docker course +(11, 25, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '10 days', CURRENT_TIMESTAMP - INTERVAL '3 days'), +(11, 26, 'IN_PROGRESS', 20, CURRENT_TIMESTAMP - INTERVAL '3 days', NULL), + +-- Student 12 (Sara): just started +(12, 1, 'IN_PROGRESS', 25, CURRENT_TIMESTAMP - INTERVAL '7 days', NULL), +(12, 18, 'IN_PROGRESS', 10, CURRENT_TIMESTAMP - INTERVAL '3 days', NULL), +(12, 22, 'COMPLETED', 100, CURRENT_TIMESTAMP - INTERVAL '14 days', CURRENT_TIMESTAMP - INTERVAL '7 days'), +(12, 23, 'IN_PROGRESS', 60, CURRENT_TIMESTAMP - INTERVAL '7 days', NULL) +ON CONFLICT (user_id, sub_course_id) DO NOTHING; + +-- ====================================================== +-- 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); diff --git a/db/migrations/000022_audio_questions.down.sql b/db/migrations/000022_audio_questions.down.sql new file mode 100644 index 0000000..8246819 --- /dev/null +++ b/db/migrations/000022_audio_questions.down.sql @@ -0,0 +1,15 @@ +-- Revert AUDIO question type changes + +-- 1. Drop question_audio_answers table +DROP TABLE IF EXISTS question_audio_answers CASCADE; + +-- 2. Remove image_url column +ALTER TABLE questions DROP COLUMN IF EXISTS image_url; + +-- 3. Revert question_type CHECK constraint +ALTER TABLE questions + DROP CONSTRAINT IF EXISTS questions_question_type_check; + +ALTER TABLE questions + ADD CONSTRAINT questions_question_type_check + CHECK (question_type IN ('MCQ', 'TRUE_FALSE', 'SHORT_ANSWER')); diff --git a/db/migrations/000022_audio_questions.up.sql b/db/migrations/000022_audio_questions.up.sql new file mode 100644 index 0000000..29fd87f --- /dev/null +++ b/db/migrations/000022_audio_questions.up.sql @@ -0,0 +1,24 @@ +-- Add AUDIO question type and image_url support + +-- 1. Extend question_type CHECK constraint to include AUDIO +ALTER TABLE questions + DROP CONSTRAINT IF EXISTS questions_question_type_check; + +ALTER TABLE questions + ADD CONSTRAINT questions_question_type_check + CHECK (question_type IN ('MCQ', 'TRUE_FALSE', 'SHORT_ANSWER', 'AUDIO')); + +-- 2. Add image_url column to questions +ALTER TABLE questions + ADD COLUMN IF NOT EXISTS image_url TEXT; + +-- 3. Create question_audio_answers table for storing correct answer text +CREATE TABLE IF NOT EXISTS question_audio_answers ( + id BIGSERIAL PRIMARY KEY, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + correct_answer_text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_question_audio_answers_question_id + ON question_audio_answers(question_id); diff --git a/db/query/learning_tree.sql b/db/query/learning_tree.sql index 275e983..62b9ece 100644 --- a/db/query/learning_tree.sql +++ b/db/query/learning_tree.sql @@ -9,3 +9,49 @@ 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, + (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, + (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.created_at; + +-- name: GetSubCoursePrerequisitesForLearningPath :many +SELECT p.prerequisite_sub_course_id, sc.title, sc.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; diff --git a/db/query/question_audio_answers.sql b/db/query/question_audio_answers.sql new file mode 100644 index 0000000..0463763 --- /dev/null +++ b/db/query/question_audio_answers.sql @@ -0,0 +1,18 @@ +-- name: CreateQuestionAudioAnswer :one +INSERT INTO question_audio_answers (question_id, correct_answer_text) +VALUES ($1, $2) +RETURNING *; + +-- name: GetAudioAnswerByQuestionID :one +SELECT * +FROM question_audio_answers +WHERE question_id = $1; + +-- name: GetAudioAnswersByQuestionIDs :many +SELECT * +FROM question_audio_answers +WHERE question_id = ANY($1::BIGINT[]); + +-- name: DeleteAudioAnswerByQuestionID :exec +DELETE FROM question_audio_answers +WHERE question_id = $1; diff --git a/db/query/question_set_items.sql b/db/query/question_set_items.sql index 1e6e189..718c022 100644 --- a/db/query/question_set_items.sql +++ b/db/query/question_set_items.sql @@ -21,6 +21,7 @@ SELECT q.explanation, q.tips, q.voice_prompt, + q.image_url, q.status as question_status FROM question_set_items qsi JOIN questions q ON q.id = qsi.question_id @@ -40,7 +41,8 @@ SELECT q.points, q.explanation, q.tips, - q.voice_prompt + q.voice_prompt, + q.image_url FROM question_set_items qsi JOIN questions q ON q.id = qsi.question_id WHERE qsi.set_id = $1 diff --git a/db/query/questions.sql b/db/query/questions.sql index 3c21744..c72cd9c 100644 --- a/db/query/questions.sql +++ b/db/query/questions.sql @@ -8,9 +8,10 @@ INSERT INTO questions ( tips, voice_prompt, sample_answer_voice_prompt, + image_url, status ) -VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, COALESCE($9, 'DRAFT')) +VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, $9, COALESCE($10, 'DRAFT')) RETURNING *; -- name: GetQuestionByID :one @@ -59,9 +60,10 @@ SET tips = COALESCE($6, tips), voice_prompt = COALESCE($7, voice_prompt), sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt), - status = COALESCE($9, status), + image_url = COALESCE($9, image_url), + status = COALESCE($10, status), updated_at = CURRENT_TIMESTAMP -WHERE id = $10; +WHERE id = $11; -- name: ArchiveQuestion :exec UPDATE questions diff --git a/gen/db/learning_tree.sql.go b/gen/db/learning_tree.sql.go index 41af19e..05e8d52 100644 --- a/gen/db/learning_tree.sql.go +++ b/gen/db/learning_tree.sql.go @@ -11,6 +11,87 @@ import ( "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, + (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"` + 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.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, @@ -57,3 +138,134 @@ func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTre } return items, nil } + +const GetSubCoursePracticesForLearningPath = `-- name: GetSubCoursePracticesForLearningPath :many +SELECT id, title, description, persona, status, + (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.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"` + 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.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 +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"` +} + +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); 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 +} diff --git a/gen/db/models.go b/gen/db/models.go index 299b678..8e3fed1 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -135,6 +135,14 @@ type Question struct { Status string `json:"status"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ImageUrl pgtype.Text `json:"image_url"` +} + +type QuestionAudioAnswer struct { + ID int64 `json:"id"` + QuestionID int64 `json:"question_id"` + CorrectAnswerText string `json:"correct_answer_text"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type QuestionOption struct { diff --git a/gen/db/question_audio_answers.sql.go b/gen/db/question_audio_answers.sql.go new file mode 100644 index 0000000..7bb05e7 --- /dev/null +++ b/gen/db/question_audio_answers.sql.go @@ -0,0 +1,92 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: question_audio_answers.sql + +package dbgen + +import ( + "context" +) + +const CreateQuestionAudioAnswer = `-- name: CreateQuestionAudioAnswer :one +INSERT INTO question_audio_answers (question_id, correct_answer_text) +VALUES ($1, $2) +RETURNING id, question_id, correct_answer_text, created_at +` + +type CreateQuestionAudioAnswerParams struct { + QuestionID int64 `json:"question_id"` + CorrectAnswerText string `json:"correct_answer_text"` +} + +func (q *Queries) CreateQuestionAudioAnswer(ctx context.Context, arg CreateQuestionAudioAnswerParams) (QuestionAudioAnswer, error) { + row := q.db.QueryRow(ctx, CreateQuestionAudioAnswer, arg.QuestionID, arg.CorrectAnswerText) + var i QuestionAudioAnswer + err := row.Scan( + &i.ID, + &i.QuestionID, + &i.CorrectAnswerText, + &i.CreatedAt, + ) + return i, err +} + +const DeleteAudioAnswerByQuestionID = `-- name: DeleteAudioAnswerByQuestionID :exec +DELETE FROM question_audio_answers +WHERE question_id = $1 +` + +func (q *Queries) DeleteAudioAnswerByQuestionID(ctx context.Context, questionID int64) error { + _, err := q.db.Exec(ctx, DeleteAudioAnswerByQuestionID, questionID) + return err +} + +const GetAudioAnswerByQuestionID = `-- name: GetAudioAnswerByQuestionID :one +SELECT id, question_id, correct_answer_text, created_at +FROM question_audio_answers +WHERE question_id = $1 +` + +func (q *Queries) GetAudioAnswerByQuestionID(ctx context.Context, questionID int64) (QuestionAudioAnswer, error) { + row := q.db.QueryRow(ctx, GetAudioAnswerByQuestionID, questionID) + var i QuestionAudioAnswer + err := row.Scan( + &i.ID, + &i.QuestionID, + &i.CorrectAnswerText, + &i.CreatedAt, + ) + return i, err +} + +const GetAudioAnswersByQuestionIDs = `-- name: GetAudioAnswersByQuestionIDs :many +SELECT id, question_id, correct_answer_text, created_at +FROM question_audio_answers +WHERE question_id = ANY($1::BIGINT[]) +` + +func (q *Queries) GetAudioAnswersByQuestionIDs(ctx context.Context, dollar_1 []int64) ([]QuestionAudioAnswer, error) { + rows, err := q.db.Query(ctx, GetAudioAnswersByQuestionIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QuestionAudioAnswer + for rows.Next() { + var i QuestionAudioAnswer + if err := rows.Scan( + &i.ID, + &i.QuestionID, + &i.CorrectAnswerText, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/gen/db/question_set_items.sql.go b/gen/db/question_set_items.sql.go index 67c5960..0443f46 100644 --- a/gen/db/question_set_items.sql.go +++ b/gen/db/question_set_items.sql.go @@ -68,7 +68,8 @@ SELECT q.points, q.explanation, q.tips, - q.voice_prompt + q.voice_prompt, + q.image_url FROM question_set_items qsi JOIN questions q ON q.id = qsi.question_id WHERE qsi.set_id = $1 @@ -88,6 +89,7 @@ type GetPublishedQuestionsInSetRow struct { Explanation pgtype.Text `json:"explanation"` Tips pgtype.Text `json:"tips"` VoicePrompt pgtype.Text `json:"voice_prompt"` + ImageUrl pgtype.Text `json:"image_url"` } func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]GetPublishedQuestionsInSetRow, error) { @@ -111,6 +113,7 @@ func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ( &i.Explanation, &i.Tips, &i.VoicePrompt, + &i.ImageUrl, ); err != nil { return nil, err } @@ -135,6 +138,7 @@ SELECT q.explanation, q.tips, q.voice_prompt, + q.image_url, q.status as question_status FROM question_set_items qsi JOIN questions q ON q.id = qsi.question_id @@ -155,6 +159,7 @@ type GetQuestionSetItemsRow struct { Explanation pgtype.Text `json:"explanation"` Tips pgtype.Text `json:"tips"` VoicePrompt pgtype.Text `json:"voice_prompt"` + ImageUrl pgtype.Text `json:"image_url"` QuestionStatus string `json:"question_status"` } @@ -179,6 +184,7 @@ func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQu &i.Explanation, &i.Tips, &i.VoicePrompt, + &i.ImageUrl, &i.QuestionStatus, ); err != nil { return nil, err diff --git a/gen/db/questions.sql.go b/gen/db/questions.sql.go index f4959f0..7b023af 100644 --- a/gen/db/questions.sql.go +++ b/gen/db/questions.sql.go @@ -32,10 +32,11 @@ INSERT INTO questions ( tips, voice_prompt, sample_answer_voice_prompt, + image_url, status ) -VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, COALESCE($9, 'DRAFT')) -RETURNING id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at +VALUES ($1, $2, $3, COALESCE($4, 1), $5, $6, $7, $8, $9, COALESCE($10, 'DRAFT')) +RETURNING id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url ` type CreateQuestionParams struct { @@ -47,7 +48,8 @@ type CreateQuestionParams struct { Tips pgtype.Text `json:"tips"` VoicePrompt pgtype.Text `json:"voice_prompt"` SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` - Column9 interface{} `json:"column_9"` + ImageUrl pgtype.Text `json:"image_url"` + Column10 interface{} `json:"column_10"` } func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) (Question, error) { @@ -60,7 +62,8 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) arg.Tips, arg.VoicePrompt, arg.SampleAnswerVoicePrompt, - arg.Column9, + arg.ImageUrl, + arg.Column10, ) var i Question err := row.Scan( @@ -76,6 +79,7 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) &i.Status, &i.CreatedAt, &i.UpdatedAt, + &i.ImageUrl, ) return i, err } @@ -91,7 +95,7 @@ func (q *Queries) DeleteQuestion(ctx context.Context, id int64) error { } const GetQuestionByID = `-- name: GetQuestionByID :one -SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at +SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url FROM questions WHERE id = $1 ` @@ -112,6 +116,7 @@ func (q *Queries) GetQuestionByID(ctx context.Context, id int64) (Question, erro &i.Status, &i.CreatedAt, &i.UpdatedAt, + &i.ImageUrl, ) return i, err } @@ -188,7 +193,7 @@ func (q *Queries) GetQuestionWithOptions(ctx context.Context, id int64) ([]GetQu } const GetQuestionsByIDs = `-- name: GetQuestionsByIDs :many -SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at +SELECT id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at, image_url FROM questions WHERE id = ANY($1::BIGINT[]) ORDER BY id @@ -216,6 +221,7 @@ func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Qu &i.Status, &i.CreatedAt, &i.UpdatedAt, + &i.ImageUrl, ); err != nil { return nil, err } @@ -230,7 +236,7 @@ func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Qu const ListQuestions = `-- name: ListQuestions :many SELECT COUNT(*) OVER () AS total_count, - q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at + q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at, q.image_url FROM questions q WHERE status != 'ARCHIVED' AND ($1::VARCHAR IS NULL OR $1 = '' OR question_type = $1) @@ -263,6 +269,7 @@ type ListQuestionsRow struct { Status string `json:"status"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ImageUrl pgtype.Text `json:"image_url"` } func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([]ListQuestionsRow, error) { @@ -294,6 +301,7 @@ func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([ &i.Status, &i.CreatedAt, &i.UpdatedAt, + &i.ImageUrl, ); err != nil { return nil, err } @@ -308,7 +316,7 @@ func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([ const SearchQuestions = `-- name: SearchQuestions :many SELECT COUNT(*) OVER () AS total_count, - q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at + q.id, q.question_text, q.question_type, q.difficulty_level, q.points, q.explanation, q.tips, q.voice_prompt, q.sample_answer_voice_prompt, q.status, q.created_at, q.updated_at, q.image_url FROM questions q WHERE status != 'ARCHIVED' AND question_text ILIKE '%' || $1 || '%' @@ -337,6 +345,7 @@ type SearchQuestionsRow struct { Status string `json:"status"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ImageUrl pgtype.Text `json:"image_url"` } func (q *Queries) SearchQuestions(ctx context.Context, arg SearchQuestionsParams) ([]SearchQuestionsRow, error) { @@ -362,6 +371,7 @@ func (q *Queries) SearchQuestions(ctx context.Context, arg SearchQuestionsParams &i.Status, &i.CreatedAt, &i.UpdatedAt, + &i.ImageUrl, ); err != nil { return nil, err } @@ -384,9 +394,10 @@ SET tips = COALESCE($6, tips), voice_prompt = COALESCE($7, voice_prompt), sample_answer_voice_prompt = COALESCE($8, sample_answer_voice_prompt), - status = COALESCE($9, status), + image_url = COALESCE($9, image_url), + status = COALESCE($10, status), updated_at = CURRENT_TIMESTAMP -WHERE id = $10 +WHERE id = $11 ` type UpdateQuestionParams struct { @@ -398,6 +409,7 @@ type UpdateQuestionParams struct { Tips pgtype.Text `json:"tips"` VoicePrompt pgtype.Text `json:"voice_prompt"` SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"` + ImageUrl pgtype.Text `json:"image_url"` Status string `json:"status"` ID int64 `json:"id"` } @@ -412,6 +424,7 @@ func (q *Queries) UpdateQuestion(ctx context.Context, arg UpdateQuestionParams) arg.Tips, arg.VoicePrompt, arg.SampleAnswerVoicePrompt, + arg.ImageUrl, arg.Status, arg.ID, ) diff --git a/internal/domain/course_management.go b/internal/domain/course_management.go index 74e6965..6f6f9a2 100644 --- a/internal/domain/course_management.go +++ b/internal/domain/course_management.go @@ -87,3 +87,58 @@ const ( VideoHostProviderDirect VideoHostProvider = "DIRECT" VideoHostProviderVimeo VideoHostProvider = "VIMEO" ) + +// Learning Path types — full nested structure for a course +type LearningPathVideo struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + VideoURL string `json:"video_url"` + Duration int32 `json:"duration"` + Resolution *string `json:"resolution,omitempty"` + DisplayOrder int32 `json:"display_order"` + VimeoID *string `json:"vimeo_id,omitempty"` + VimeoEmbedURL *string `json:"vimeo_embed_url,omitempty"` + VideoHostProvider *string `json:"video_host_provider,omitempty"` +} + +type LearningPathPractice struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + Persona *string `json:"persona,omitempty"` + Status string `json:"status"` + QuestionCount int64 `json:"question_count"` +} + +type LearningPathPrerequisite struct { + SubCourseID int64 `json:"sub_course_id"` + Title string `json:"title"` + Level string `json:"level"` +} + +type LearningPathSubCourse struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` + DisplayOrder int32 `json:"display_order"` + Level string `json:"level"` + PrerequisiteCount int64 `json:"prerequisite_count"` + VideoCount int64 `json:"video_count"` + PracticeCount int64 `json:"practice_count"` + Prerequisites []LearningPathPrerequisite `json:"prerequisites"` + Videos []LearningPathVideo `json:"videos"` + Practices []LearningPathPractice `json:"practices"` +} + +type LearningPath struct { + CourseID int64 `json:"course_id"` + CourseTitle string `json:"course_title"` + Description *string `json:"description,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` + IntroVideoURL *string `json:"intro_video_url,omitempty"` + CategoryID int64 `json:"category_id"` + CategoryName string `json:"category_name"` + SubCourses []LearningPathSubCourse `json:"sub_courses"` +} diff --git a/internal/domain/questions.go b/internal/domain/questions.go index d2534e3..958db71 100644 --- a/internal/domain/questions.go +++ b/internal/domain/questions.go @@ -8,6 +8,7 @@ const ( QuestionTypeMCQ QuestionType = "MCQ" QuestionTypeTrueFalse QuestionType = "TRUE_FALSE" QuestionTypeShortAnswer QuestionType = "SHORT_ANSWER" + QuestionTypeAudio QuestionType = "AUDIO" ) type DifficultyLevel string @@ -46,15 +47,24 @@ type Question struct { Tips *string VoicePrompt *string SampleAnswerVoicePrompt *string + ImageURL *string Status string CreatedAt time.Time UpdatedAt *time.Time } +type QuestionAudioAnswer struct { + ID int64 + QuestionID int64 + CorrectAnswerText string + CreatedAt time.Time +} + type QuestionWithDetails struct { Question Options []QuestionOption ShortAnswers []QuestionShortAnswer + AudioAnswer *QuestionAudioAnswer } type QuestionOption struct { @@ -110,6 +120,7 @@ type QuestionSetItemWithQuestion struct { Explanation *string Tips *string VoicePrompt *string + ImageURL *string QuestionStatus string } @@ -122,9 +133,11 @@ type CreateQuestionInput struct { Tips *string VoicePrompt *string SampleAnswerVoicePrompt *string + ImageURL *string Status *string Options []CreateQuestionOptionInput ShortAnswers []CreateShortAnswerInput + AudioCorrectAnswerText *string } type CreateQuestionOptionInput struct { diff --git a/internal/ports/course_management.go b/internal/ports/course_management.go index b6505ec..4aeabd3 100644 --- a/internal/ports/course_management.go +++ b/internal/ports/course_management.go @@ -172,6 +172,9 @@ type CourseStore interface { // Learning Tree GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) + + // Learning Path (full nested structure for a course) + GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) } type ProgressionStore interface { diff --git a/internal/repository/learning_tree.go b/internal/repository/learning_tree.go index 536bc1d..d5e3edc 100644 --- a/internal/repository/learning_tree.go +++ b/internal/repository/learning_tree.go @@ -3,6 +3,9 @@ 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) { @@ -41,3 +44,115 @@ func (s *Store) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, e 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, + 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, + } + } + 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, + QuestionCount: row.QuestionCount, + } + } + return result, nil +} diff --git a/internal/repository/questions.go b/internal/repository/questions.go index dd90786..076b3d3 100644 --- a/internal/repository/questions.go +++ b/internal/repository/questions.go @@ -70,6 +70,7 @@ func questionToDomain(q dbgen.Question) domain.Question { Tips: fromPgText(q.Tips), VoicePrompt: fromPgText(q.VoicePrompt), SampleAnswerVoicePrompt: fromPgText(q.SampleAnswerVoicePrompt), + ImageURL: fromPgText(q.ImageUrl), Status: q.Status, CreatedAt: q.CreatedAt.Time, UpdatedAt: timePtr(q.UpdatedAt), @@ -97,6 +98,15 @@ func questionShortAnswerToDomain(a dbgen.QuestionShortAnswer) domain.QuestionSho } } +func questionAudioAnswerToDomain(a dbgen.QuestionAudioAnswer) domain.QuestionAudioAnswer { + return domain.QuestionAudioAnswer{ + ID: a.ID, + QuestionID: a.QuestionID, + CorrectAnswerText: a.CorrectAnswerText, + CreatedAt: a.CreatedAt.Time, + } +} + func questionSetToDomain(qs dbgen.QuestionSet) domain.QuestionSet { return domain.QuestionSet{ ID: qs.ID, @@ -152,7 +162,8 @@ func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionI Tips: toPgText(input.Tips), VoicePrompt: toPgText(input.VoicePrompt), SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt), - Column9: status, + ImageUrl: toPgText(input.ImageURL), + Column10: status, }) if err != nil { return domain.Question{}, err @@ -189,6 +200,16 @@ func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionI } } + if input.AudioCorrectAnswerText != nil && *input.AudioCorrectAnswerText != "" { + _, err = q.CreateQuestionAudioAnswer(ctx, dbgen.CreateQuestionAudioAnswerParams{ + QuestionID: question.ID, + CorrectAnswerText: *input.AudioCorrectAnswerText, + }) + if err != nil { + return domain.Question{}, err + } + } + if err = tx.Commit(ctx); err != nil { return domain.Question{}, err } @@ -230,10 +251,18 @@ func (s *Store) GetQuestionWithDetails(ctx context.Context, id int64) (domain.Qu answers[i] = questionShortAnswerToDomain(a) } + var audioAnswer *domain.QuestionAudioAnswer + aa, err := s.queries.GetAudioAnswerByQuestionID(ctx, id) + if err == nil { + mapped := questionAudioAnswerToDomain(aa) + audioAnswer = &mapped + } + return domain.QuestionWithDetails{ Question: questionToDomain(q), Options: options, ShortAnswers: answers, + AudioAnswer: audioAnswer, }, nil } @@ -276,6 +305,7 @@ func (s *Store) ListQuestions(ctx context.Context, questionType, difficulty, sta Tips: fromPgText(r.Tips), VoicePrompt: fromPgText(r.VoicePrompt), SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), + ImageURL: fromPgText(r.ImageUrl), Status: r.Status, CreatedAt: r.CreatedAt.Time, UpdatedAt: timePtr(r.UpdatedAt), @@ -311,6 +341,7 @@ func (s *Store) SearchQuestions(ctx context.Context, query string, limit, offset Tips: fromPgText(r.Tips), VoicePrompt: fromPgText(r.VoicePrompt), SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt), + ImageURL: fromPgText(r.ImageUrl), Status: r.Status, CreatedAt: r.CreatedAt.Time, UpdatedAt: timePtr(r.UpdatedAt), @@ -330,7 +361,7 @@ func (s *Store) UpdateQuestion(ctx context.Context, id int64, input domain.Creat status = *input.Status } - return s.queries.UpdateQuestion(ctx, dbgen.UpdateQuestionParams{ + err := s.queries.UpdateQuestion(ctx, dbgen.UpdateQuestionParams{ ID: id, QuestionText: input.QuestionText, QuestionType: input.QuestionType, @@ -340,8 +371,27 @@ func (s *Store) UpdateQuestion(ctx context.Context, id int64, input domain.Creat Tips: toPgText(input.Tips), VoicePrompt: toPgText(input.VoicePrompt), SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt), + ImageUrl: toPgText(input.ImageURL), Status: status, }) + if err != nil { + return err + } + + if input.AudioCorrectAnswerText != nil { + _ = s.queries.DeleteAudioAnswerByQuestionID(ctx, id) + if *input.AudioCorrectAnswerText != "" { + _, err = s.queries.CreateQuestionAudioAnswer(ctx, dbgen.CreateQuestionAudioAnswerParams{ + QuestionID: id, + CorrectAnswerText: *input.AudioCorrectAnswerText, + }) + if err != nil { + return err + } + } + } + + return nil } func (s *Store) ArchiveQuestion(ctx context.Context, id int64) error { @@ -653,6 +703,7 @@ func (s *Store) GetQuestionSetItems(ctx context.Context, setID int64) ([]domain. Explanation: fromPgText(r.Explanation), Tips: fromPgText(r.Tips), VoicePrompt: fromPgText(r.VoicePrompt), + ImageURL: fromPgText(r.ImageUrl), QuestionStatus: r.QuestionStatus, } } @@ -681,6 +732,7 @@ func (s *Store) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([] Explanation: fromPgText(r.Explanation), Tips: fromPgText(r.Tips), VoicePrompt: fromPgText(r.VoicePrompt), + ImageURL: fromPgText(r.ImageUrl), QuestionStatus: "PUBLISHED", } } diff --git a/internal/services/course_management/learning_tree.go b/internal/services/course_management/learning_tree.go index f294920..b5ccb64 100644 --- a/internal/services/course_management/learning_tree.go +++ b/internal/services/course_management/learning_tree.go @@ -8,3 +8,7 @@ import ( 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) +} diff --git a/internal/web_server/handlers/course_management.go b/internal/web_server/handlers/course_management.go index 684ab20..ed06f4e 100644 --- a/internal/web_server/handlers/course_management.go +++ b/internal/web_server/handlers/course_management.go @@ -1420,6 +1420,41 @@ func (h *Handler) GetFullLearningTree(c *fiber.Ctx) error { }) } +// GetCourseLearningPath godoc +// @Summary Get course learning path +// @Description Returns the complete learning path for a course including sub-courses (by level), +// @Description video lessons, practices, prerequisites, and counts — structured for admin panel flexible path configuration +// @Tags learning-tree +// @Produce json +// @Param courseId path int true "Course ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/courses/{courseId}/learning-path [get] +func (h *Handler) GetCourseLearningPath(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(), + }) + } + + path, err := h.courseMgmtSvc.GetCourseLearningPath(c.Context(), courseID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Course not found or has no learning path", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Learning path retrieved successfully", + Data: path, + }) +} + // UploadSubCourseVideo godoc // @Summary Upload a video file and create sub-course video // @Description Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 861a1b8..34d21af 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -25,16 +25,18 @@ type shortAnswerInput struct { type createQuestionReq struct { QuestionText string `json:"question_text" validate:"required"` - QuestionType string `json:"question_type" validate:"required,oneof=MCQ TRUE_FALSE SHORT_ANSWER"` + QuestionType string `json:"question_type" validate:"required,oneof=MCQ TRUE_FALSE SHORT_ANSWER AUDIO"` DifficultyLevel *string `json:"difficulty_level"` Points *int32 `json:"points"` Explanation *string `json:"explanation"` Tips *string `json:"tips"` VoicePrompt *string `json:"voice_prompt"` SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"` + ImageURL *string `json:"image_url"` Status *string `json:"status"` Options []optionInput `json:"options"` ShortAnswers []shortAnswerInput `json:"short_answers"` + AudioCorrectAnswerText *string `json:"audio_correct_answer_text"` } type optionRes struct { @@ -60,10 +62,12 @@ type questionRes struct { Tips *string `json:"tips,omitempty"` VoicePrompt *string `json:"voice_prompt,omitempty"` SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"` + ImageURL *string `json:"image_url,omitempty"` Status string `json:"status"` CreatedAt string `json:"created_at"` Options []optionRes `json:"options,omitempty"` ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"` + AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"` } type listQuestionsRes struct { @@ -119,9 +123,11 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error { Tips: req.Tips, VoicePrompt: req.VoicePrompt, SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt, + ImageURL: req.ImageURL, Status: req.Status, Options: options, ShortAnswers: shortAnswers, + AudioCorrectAnswerText: req.AudioCorrectAnswerText, } question, err := h.questionsSvc.CreateQuestion(c.Context(), input) @@ -151,6 +157,7 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error { Tips: question.Tips, VoicePrompt: question.VoicePrompt, SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, + ImageURL: question.ImageURL, Status: question.Status, CreatedAt: question.CreatedAt.String(), }, @@ -204,6 +211,11 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error { }) } + var audioCorrectAnswerText *string + if question.AudioAnswer != nil { + audioCorrectAnswerText = &question.AudioAnswer.CorrectAnswerText + } + return c.JSON(domain.Response{ Message: "Question retrieved successfully", Data: questionRes{ @@ -216,10 +228,12 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error { Tips: question.Tips, VoicePrompt: question.VoicePrompt, SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt, + ImageURL: question.ImageURL, Status: question.Status, CreatedAt: question.CreatedAt.String(), Options: options, ShortAnswers: shortAnswers, + AudioCorrectAnswerText: audioCorrectAnswerText, }, }) } @@ -356,9 +370,11 @@ type updateQuestionReq struct { Tips *string `json:"tips"` VoicePrompt *string `json:"voice_prompt"` SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"` + ImageURL *string `json:"image_url"` Status *string `json:"status"` Options []optionInput `json:"options"` ShortAnswers []shortAnswerInput `json:"short_answers"` + AudioCorrectAnswerText *string `json:"audio_correct_answer_text"` } // UpdateQuestion godoc @@ -426,9 +442,11 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error { Tips: req.Tips, VoicePrompt: req.VoicePrompt, SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt, + ImageURL: req.ImageURL, Status: req.Status, Options: options, ShortAnswers: shortAnswers, + AudioCorrectAnswerText: req.AudioCorrectAnswerText, } err = h.questionsSvc.UpdateQuestion(c.Context(), id, input) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index fd9d066..4ae192c 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -111,6 +111,7 @@ func (a *App) initAppRoutes() { // 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) // Questions groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion)