customer RBAC
This commit is contained in:
parent
d470b024b4
commit
0226275d47
316
db/data/007_course_management_seed.sql
Normal file
316
db/data/007_course_management_seed.sql
Normal file
|
|
@ -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);
|
||||
15
db/migrations/000022_audio_questions.down.sql
Normal file
15
db/migrations/000022_audio_questions.down.sql
Normal file
|
|
@ -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'));
|
||||
24
db/migrations/000022_audio_questions.up.sql
Normal file
24
db/migrations/000022_audio_questions.up.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
18
db/query/question_audio_answers.sql
Normal file
18
db/query/question_audio_answers.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
92
gen/db/question_audio_answers.sql.go
Normal file
92
gen/db/question_audio_answers.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user