Compare commits
3 Commits
d470b024b4
...
3500db6435
| Author | SHA1 | Date | |
|---|---|---|---|
| 3500db6435 | |||
| f9da45da62 | |||
| 0226275d47 |
66
README.md
66
README.md
|
|
@ -90,17 +90,17 @@ created_at – Audit timestamp
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
|
|
||||||
One Course Category → Many Courses
|
One Course Category → Many Course Sub-categories
|
||||||
|
|
||||||
Course Category
|
Course Category
|
||||||
└── Courses[]
|
└── Course Sub-categories[]
|
||||||
|
|
||||||
2. Course
|
2. Course Sub-category
|
||||||
|
|
||||||
Table: courses
|
Table: course_sub_categories
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
Represents a full course offering under a category.
|
A grouping within a category (e.g., Speaking, Listening under Learning English).
|
||||||
|
|
||||||
Key Fields:
|
Key Fields:
|
||||||
|
|
||||||
|
|
@ -114,23 +114,23 @@ Relationships:
|
||||||
|
|
||||||
Belongs to one Course Category
|
Belongs to one Course Category
|
||||||
|
|
||||||
Has many Sub-courses
|
Has many Courses
|
||||||
|
|
||||||
Course Category
|
Course Category
|
||||||
└── Course
|
└── Course Sub-category
|
||||||
└── Sub-courses[]
|
└── Courses[]
|
||||||
|
|
||||||
3. Sub-course
|
3. Course
|
||||||
|
|
||||||
Table: sub_courses
|
Table: courses
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
A learning unit within a course representing different skill levels
|
A learning unit within a sub-category representing different skill levels
|
||||||
(e.g., Beginner, Intermediate, Advanced).
|
(e.g., Beginner, Intermediate, Advanced).
|
||||||
|
|
||||||
Key Fields:
|
Key Fields:
|
||||||
|
|
||||||
course_id – FK → courses.id
|
sub_category_id – FK → course_sub_categories.id
|
||||||
|
|
||||||
title, description
|
title, description
|
||||||
|
|
||||||
|
|
@ -144,27 +144,27 @@ is_active
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
|
|
||||||
Belongs to one Course
|
Belongs to one Course Sub-category
|
||||||
|
|
||||||
Has many Sub-course Videos
|
Has many Course Videos
|
||||||
|
|
||||||
Has many Practices
|
Has many Practices
|
||||||
|
|
||||||
Course
|
Course Sub-category
|
||||||
└── Sub-course
|
└── Course
|
||||||
├── Sub-course Videos[]
|
├── Course Videos[]
|
||||||
└── Practices[]
|
└── Practices[]
|
||||||
|
|
||||||
4. Sub-course Video
|
4. Course Video
|
||||||
|
|
||||||
Table: sub_course_videos
|
Table: course_videos
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
Video learning content attached to a sub-course.
|
Video learning content attached to a course.
|
||||||
|
|
||||||
Key Fields:
|
Key Fields:
|
||||||
|
|
||||||
sub_course_id – FK → sub_courses.id
|
course_id – FK → courses.id
|
||||||
|
|
||||||
title, description
|
title, description
|
||||||
|
|
||||||
|
|
@ -190,21 +190,21 @@ is_active
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
|
|
||||||
Belongs to one Sub-course
|
Belongs to one Course
|
||||||
|
|
||||||
Sub-course
|
Course
|
||||||
└── Sub-course Video
|
└── Course Video
|
||||||
|
|
||||||
5. Practice
|
5. Practice
|
||||||
|
|
||||||
Table: practices
|
Table: practices
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
Exercises or assessments that belong to a sub-course.
|
Exercises or assessments that belong to a course.
|
||||||
|
|
||||||
Key Fields:
|
Key Fields:
|
||||||
|
|
||||||
sub_course_id – FK → sub_courses.id
|
course_id – FK → courses.id
|
||||||
|
|
||||||
title, description
|
title, description
|
||||||
|
|
||||||
|
|
@ -216,11 +216,11 @@ is_active
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
|
|
||||||
Belongs to one Sub-course
|
Belongs to one Course
|
||||||
|
|
||||||
One Practice → Many Practice Questions
|
One Practice → Many Practice Questions
|
||||||
|
|
||||||
Sub-course
|
Course
|
||||||
└── Practice
|
└── Practice
|
||||||
└── Practice Questions[]
|
└── Practice Questions[]
|
||||||
|
|
||||||
|
|
@ -258,17 +258,17 @@ Practice
|
||||||
|
|
||||||
Complete Hierarchical Flow (Compact View)
|
Complete Hierarchical Flow (Compact View)
|
||||||
Course Category
|
Course Category
|
||||||
└── Course
|
└── Course Sub-category
|
||||||
└── Sub-course (with levels: BEGINNER, INTERMEDIATE, ADVANCED)
|
└── Course (with levels: BEGINNER, INTERMEDIATE, ADVANCED)
|
||||||
├── Sub-course Video
|
├── Course Video
|
||||||
└── Practice
|
└── Practice
|
||||||
└── Practice Question
|
└── Practice Question
|
||||||
|
|
||||||
Architectural Observations
|
Architectural Observations
|
||||||
|
|
||||||
Simple three-level hierarchy: Category → Course → Sub-course
|
Simple three-level hierarchy: Category → Sub-category → Course
|
||||||
|
|
||||||
Level is now a property of sub-course, not a separate entity
|
Level is now a property of Course, not a separate entity
|
||||||
|
|
||||||
Cascade deletes ensure referential integrity
|
Cascade deletes ensure referential integrity
|
||||||
|
|
||||||
|
|
|
||||||
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);
|
||||||
3
db/migrations/000023_reorder_support.down.sql
Normal file
3
db/migrations/000023_reorder_support.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE course_categories DROP COLUMN display_order;
|
||||||
|
ALTER TABLE courses DROP COLUMN display_order;
|
||||||
|
ALTER TABLE question_sets DROP COLUMN display_order;
|
||||||
3
db/migrations/000023_reorder_support.up.sql
Normal file
3
db/migrations/000023_reorder_support.up.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE course_categories ADD COLUMN display_order INT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE courses ADD COLUMN display_order INT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE question_sets ADD COLUMN display_order INT NOT NULL DEFAULT 0;
|
||||||
|
|
@ -21,7 +21,7 @@ SELECT
|
||||||
is_active,
|
is_active,
|
||||||
created_at
|
created_at
|
||||||
FROM course_categories
|
FROM course_categories
|
||||||
ORDER BY created_at DESC
|
ORDER BY display_order ASC, created_at DESC
|
||||||
LIMIT sqlc.narg('limit')::INT
|
LIMIT sqlc.narg('limit')::INT
|
||||||
OFFSET sqlc.narg('offset')::INT;
|
OFFSET sqlc.narg('offset')::INT;
|
||||||
|
|
||||||
|
|
@ -37,3 +37,11 @@ WHERE id = $3;
|
||||||
-- name: DeleteCourseCategory :exec
|
-- name: DeleteCourseCategory :exec
|
||||||
DELETE FROM course_categories
|
DELETE FROM course_categories
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ReorderCourseCategories :exec
|
||||||
|
UPDATE course_categories
|
||||||
|
SET display_order = bulk.position
|
||||||
|
FROM (
|
||||||
|
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
|
||||||
|
) AS bulk
|
||||||
|
WHERE course_categories.id = bulk.id;
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ SELECT
|
||||||
is_active
|
is_active
|
||||||
FROM courses
|
FROM courses
|
||||||
WHERE category_id = $1
|
WHERE category_id = $1
|
||||||
ORDER BY id DESC
|
ORDER BY display_order ASC, id ASC
|
||||||
LIMIT sqlc.narg('limit')::INT
|
LIMIT sqlc.narg('limit')::INT
|
||||||
OFFSET sqlc.narg('offset')::INT;
|
OFFSET sqlc.narg('offset')::INT;
|
||||||
|
|
||||||
|
|
@ -48,3 +48,11 @@ WHERE id = $6;
|
||||||
-- name: DeleteCourse :exec
|
-- name: DeleteCourse :exec
|
||||||
DELETE FROM courses
|
DELETE FROM courses
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ReorderCourses :exec
|
||||||
|
UPDATE courses
|
||||||
|
SET display_order = bulk.position
|
||||||
|
FROM (
|
||||||
|
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
|
||||||
|
) AS bulk
|
||||||
|
WHERE courses.id = bulk.id;
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,49 @@ FROM courses c
|
||||||
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
|
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
|
||||||
WHERE c.is_active = true
|
WHERE c.is_active = true
|
||||||
ORDER BY c.id, sc.display_order, sc.id;
|
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.display_order ASC, 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.explanation,
|
||||||
q.tips,
|
q.tips,
|
||||||
q.voice_prompt,
|
q.voice_prompt,
|
||||||
|
q.image_url,
|
||||||
q.status as question_status
|
q.status as question_status
|
||||||
FROM question_set_items qsi
|
FROM question_set_items qsi
|
||||||
JOIN questions q ON q.id = qsi.question_id
|
JOIN questions q ON q.id = qsi.question_id
|
||||||
|
|
@ -40,7 +41,8 @@ SELECT
|
||||||
q.points,
|
q.points,
|
||||||
q.explanation,
|
q.explanation,
|
||||||
q.tips,
|
q.tips,
|
||||||
q.voice_prompt
|
q.voice_prompt,
|
||||||
|
q.image_url
|
||||||
FROM question_set_items qsi
|
FROM question_set_items qsi
|
||||||
JOIN questions q ON q.id = qsi.question_id
|
JOIN questions q ON q.id = qsi.question_id
|
||||||
WHERE qsi.set_id = $1
|
WHERE qsi.set_id = $1
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ FROM question_sets
|
||||||
WHERE owner_type = $1
|
WHERE owner_type = $1
|
||||||
AND owner_id = $2
|
AND owner_id = $2
|
||||||
AND status != 'ARCHIVED'
|
AND status != 'ARCHIVED'
|
||||||
ORDER BY created_at DESC;
|
ORDER BY display_order ASC, created_at DESC;
|
||||||
|
|
||||||
-- name: GetQuestionSetsByType :many
|
-- name: GetQuestionSetsByType :many
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -114,3 +114,11 @@ SET
|
||||||
sub_course_video_id = $1,
|
sub_course_video_id = $1,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $2;
|
WHERE id = $2;
|
||||||
|
|
||||||
|
-- name: ReorderQuestionSets :exec
|
||||||
|
UPDATE question_sets
|
||||||
|
SET display_order = bulk.position
|
||||||
|
FROM (
|
||||||
|
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
|
||||||
|
) AS bulk
|
||||||
|
WHERE question_sets.id = bulk.id;
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ INSERT INTO questions (
|
||||||
tips,
|
tips,
|
||||||
voice_prompt,
|
voice_prompt,
|
||||||
sample_answer_voice_prompt,
|
sample_answer_voice_prompt,
|
||||||
|
image_url,
|
||||||
status
|
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 *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetQuestionByID :one
|
-- name: GetQuestionByID :one
|
||||||
|
|
@ -59,9 +60,10 @@ SET
|
||||||
tips = COALESCE($6, tips),
|
tips = COALESCE($6, tips),
|
||||||
voice_prompt = COALESCE($7, voice_prompt),
|
voice_prompt = COALESCE($7, voice_prompt),
|
||||||
sample_answer_voice_prompt = COALESCE($8, sample_answer_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
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $10;
|
WHERE id = $11;
|
||||||
|
|
||||||
-- name: ArchiveQuestion :exec
|
-- name: ArchiveQuestion :exec
|
||||||
UPDATE questions
|
UPDATE questions
|
||||||
|
|
|
||||||
|
|
@ -112,3 +112,11 @@ WHERE id = $1;
|
||||||
-- name: DeleteSubCourseVideo :exec
|
-- name: DeleteSubCourseVideo :exec
|
||||||
DELETE FROM sub_course_videos
|
DELETE FROM sub_course_videos
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ReorderSubCourseVideos :exec
|
||||||
|
UPDATE sub_course_videos
|
||||||
|
SET display_order = bulk.position
|
||||||
|
FROM (
|
||||||
|
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
|
||||||
|
) AS bulk
|
||||||
|
WHERE sub_course_videos.id = bulk.id;
|
||||||
|
|
|
||||||
|
|
@ -80,3 +80,11 @@ RETURNING *;
|
||||||
UPDATE sub_courses
|
UPDATE sub_courses
|
||||||
SET is_active = FALSE
|
SET is_active = FALSE
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ReorderSubCourses :exec
|
||||||
|
UPDATE sub_courses
|
||||||
|
SET display_order = bulk.position
|
||||||
|
FROM (
|
||||||
|
SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position
|
||||||
|
) AS bulk
|
||||||
|
WHERE sub_courses.id = bulk.id;
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,13 @@ SET
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $2;
|
WHERE id = $2;
|
||||||
|
|
||||||
|
-- name: GetUserSummary :one
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_users,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'ACTIVE') AS active_users,
|
||||||
|
COUNT(*) FILTER (WHERE created_at >= date_trunc('month', CURRENT_DATE)) AS joined_this_month
|
||||||
|
FROM users;
|
||||||
|
|
||||||
-- name: UpdateUserKnowledgeLevel :exec
|
-- name: UpdateUserKnowledgeLevel :exec
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ services:
|
||||||
container_name: yimaru-backend-postgres-1
|
container_name: yimaru-backend-postgres-1
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
ports:
|
ports:
|
||||||
- "5432:5422"
|
- "5592:5422"
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_PASSWORD=secret
|
- POSTGRES_PASSWORD=secret
|
||||||
- POSTGRES_USER=root
|
- POSTGRES_USER=root
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ INSERT INTO course_categories (
|
||||||
is_active
|
is_active
|
||||||
)
|
)
|
||||||
VALUES ($1, COALESCE($2, true))
|
VALUES ($1, COALESCE($2, true))
|
||||||
RETURNING id, name, is_active, created_at
|
RETURNING id, name, is_active, created_at, display_order
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateCourseCategoryParams struct {
|
type CreateCourseCategoryParams struct {
|
||||||
|
|
@ -33,6 +33,7 @@ func (q *Queries) CreateCourseCategory(ctx context.Context, arg CreateCourseCate
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +56,7 @@ SELECT
|
||||||
is_active,
|
is_active,
|
||||||
created_at
|
created_at
|
||||||
FROM course_categories
|
FROM course_categories
|
||||||
ORDER BY created_at DESC
|
ORDER BY display_order ASC, created_at DESC
|
||||||
LIMIT $2::INT
|
LIMIT $2::INT
|
||||||
OFFSET $1::INT
|
OFFSET $1::INT
|
||||||
`
|
`
|
||||||
|
|
@ -100,7 +101,7 @@ func (q *Queries) GetAllCourseCategories(ctx context.Context, arg GetAllCourseCa
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetCourseCategoryByID = `-- name: GetCourseCategoryByID :one
|
const GetCourseCategoryByID = `-- name: GetCourseCategoryByID :one
|
||||||
SELECT id, name, is_active, created_at
|
SELECT id, name, is_active, created_at, display_order
|
||||||
FROM course_categories
|
FROM course_categories
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -113,10 +114,30 @@ func (q *Queries) GetCourseCategoryByID(ctx context.Context, id int64) (CourseCa
|
||||||
&i.Name,
|
&i.Name,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ReorderCourseCategories = `-- name: ReorderCourseCategories :exec
|
||||||
|
UPDATE course_categories
|
||||||
|
SET display_order = bulk.position
|
||||||
|
FROM (
|
||||||
|
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
|
||||||
|
) AS bulk
|
||||||
|
WHERE course_categories.id = bulk.id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ReorderCourseCategoriesParams struct {
|
||||||
|
Ids []int64 `json:"ids"`
|
||||||
|
Positions []int32 `json:"positions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ReorderCourseCategories(ctx context.Context, arg ReorderCourseCategoriesParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, ReorderCourseCategories, arg.Ids, arg.Positions)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const UpdateCourseCategory = `-- name: UpdateCourseCategory :exec
|
const UpdateCourseCategory = `-- name: UpdateCourseCategory :exec
|
||||||
UPDATE course_categories
|
UPDATE course_categories
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ INSERT INTO courses (
|
||||||
is_active
|
is_active
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
|
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true))
|
||||||
RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url
|
RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateCourseParams struct {
|
type CreateCourseParams struct {
|
||||||
|
|
@ -51,6 +51,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
&i.IntroVideoUrl,
|
&i.IntroVideoUrl,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +67,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetCourseByID = `-- name: GetCourseByID :one
|
const GetCourseByID = `-- name: GetCourseByID :one
|
||||||
SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url
|
SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order
|
||||||
FROM courses
|
FROM courses
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -82,6 +83,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) {
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
&i.IntroVideoUrl,
|
&i.IntroVideoUrl,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +100,7 @@ SELECT
|
||||||
is_active
|
is_active
|
||||||
FROM courses
|
FROM courses
|
||||||
WHERE category_id = $1
|
WHERE category_id = $1
|
||||||
ORDER BY id DESC
|
ORDER BY display_order ASC, id ASC
|
||||||
LIMIT $3::INT
|
LIMIT $3::INT
|
||||||
OFFSET $2::INT
|
OFFSET $2::INT
|
||||||
`
|
`
|
||||||
|
|
@ -149,6 +151,25 @@ func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCate
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ReorderCourses = `-- name: ReorderCourses :exec
|
||||||
|
UPDATE courses
|
||||||
|
SET display_order = bulk.position
|
||||||
|
FROM (
|
||||||
|
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
|
||||||
|
) AS bulk
|
||||||
|
WHERE courses.id = bulk.id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ReorderCoursesParams struct {
|
||||||
|
Ids []int64 `json:"ids"`
|
||||||
|
Positions []int32 `json:"positions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ReorderCourses(ctx context.Context, arg ReorderCoursesParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, ReorderCourses, arg.Ids, arg.Positions)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const UpdateCourse = `-- name: UpdateCourse :exec
|
const UpdateCourse = `-- name: UpdateCourse :exec
|
||||||
UPDATE courses
|
UPDATE courses
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,87 @@ import (
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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
|
const GetFullLearningTree = `-- name: GetFullLearningTree :many
|
||||||
SELECT
|
SELECT
|
||||||
c.id AS course_id,
|
c.id AS course_id,
|
||||||
|
|
@ -57,3 +138,134 @@ func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTre
|
||||||
}
|
}
|
||||||
return items, nil
|
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.display_order ASC, qs.created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetSubCoursePracticesForLearningPathRow struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description pgtype.Text `json:"description"`
|
||||||
|
Persona pgtype.Text `json:"persona"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ type Course struct {
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
|
IntroVideoUrl pgtype.Text `json:"intro_video_url"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CourseCategory struct {
|
type CourseCategory struct {
|
||||||
|
|
@ -37,6 +38,7 @@ type CourseCategory struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Device struct {
|
type Device struct {
|
||||||
|
|
@ -135,6 +137,14 @@ type Question struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_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 {
|
type QuestionOption struct {
|
||||||
|
|
@ -162,6 +172,7 @@ type QuestionSet struct {
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
|
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuestionSetItem struct {
|
type QuestionSetItem 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.points,
|
||||||
q.explanation,
|
q.explanation,
|
||||||
q.tips,
|
q.tips,
|
||||||
q.voice_prompt
|
q.voice_prompt,
|
||||||
|
q.image_url
|
||||||
FROM question_set_items qsi
|
FROM question_set_items qsi
|
||||||
JOIN questions q ON q.id = qsi.question_id
|
JOIN questions q ON q.id = qsi.question_id
|
||||||
WHERE qsi.set_id = $1
|
WHERE qsi.set_id = $1
|
||||||
|
|
@ -88,6 +89,7 @@ type GetPublishedQuestionsInSetRow struct {
|
||||||
Explanation pgtype.Text `json:"explanation"`
|
Explanation pgtype.Text `json:"explanation"`
|
||||||
Tips pgtype.Text `json:"tips"`
|
Tips pgtype.Text `json:"tips"`
|
||||||
VoicePrompt pgtype.Text `json:"voice_prompt"`
|
VoicePrompt pgtype.Text `json:"voice_prompt"`
|
||||||
|
ImageUrl pgtype.Text `json:"image_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]GetPublishedQuestionsInSetRow, error) {
|
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.Explanation,
|
||||||
&i.Tips,
|
&i.Tips,
|
||||||
&i.VoicePrompt,
|
&i.VoicePrompt,
|
||||||
|
&i.ImageUrl,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -135,6 +138,7 @@ SELECT
|
||||||
q.explanation,
|
q.explanation,
|
||||||
q.tips,
|
q.tips,
|
||||||
q.voice_prompt,
|
q.voice_prompt,
|
||||||
|
q.image_url,
|
||||||
q.status as question_status
|
q.status as question_status
|
||||||
FROM question_set_items qsi
|
FROM question_set_items qsi
|
||||||
JOIN questions q ON q.id = qsi.question_id
|
JOIN questions q ON q.id = qsi.question_id
|
||||||
|
|
@ -155,6 +159,7 @@ type GetQuestionSetItemsRow struct {
|
||||||
Explanation pgtype.Text `json:"explanation"`
|
Explanation pgtype.Text `json:"explanation"`
|
||||||
Tips pgtype.Text `json:"tips"`
|
Tips pgtype.Text `json:"tips"`
|
||||||
VoicePrompt pgtype.Text `json:"voice_prompt"`
|
VoicePrompt pgtype.Text `json:"voice_prompt"`
|
||||||
|
ImageUrl pgtype.Text `json:"image_url"`
|
||||||
QuestionStatus string `json:"question_status"`
|
QuestionStatus string `json:"question_status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,6 +184,7 @@ func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQu
|
||||||
&i.Explanation,
|
&i.Explanation,
|
||||||
&i.Tips,
|
&i.Tips,
|
||||||
&i.VoicePrompt,
|
&i.VoicePrompt,
|
||||||
|
&i.ImageUrl,
|
||||||
&i.QuestionStatus,
|
&i.QuestionStatus,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -192,7 +198,7 @@ func (q *Queries) GetQuestionSetItems(ctx context.Context, setID int64) ([]GetQu
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many
|
const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many
|
||||||
SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id
|
SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order
|
||||||
FROM question_sets qs
|
FROM question_sets qs
|
||||||
JOIN question_set_items qsi ON qsi.set_id = qs.id
|
JOIN question_set_items qsi ON qsi.set_id = qs.id
|
||||||
WHERE qsi.question_id = $1
|
WHERE qsi.question_id = $1
|
||||||
|
|
@ -224,6 +230,7 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ INSERT INTO question_sets (
|
||||||
sub_course_video_id
|
sub_course_video_id
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12)
|
||||||
RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id
|
RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateQuestionSetParams struct {
|
type CreateQuestionSetParams struct {
|
||||||
|
|
@ -117,6 +117,7 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +133,7 @@ func (q *Queries) DeleteQuestionSet(ctx context.Context, id int64) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetInitialAssessmentSet = `-- name: GetInitialAssessmentSet :one
|
const GetInitialAssessmentSet = `-- name: GetInitialAssessmentSet :one
|
||||||
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id
|
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order
|
||||||
FROM question_sets
|
FROM question_sets
|
||||||
WHERE set_type = 'INITIAL_ASSESSMENT'
|
WHERE set_type = 'INITIAL_ASSESSMENT'
|
||||||
AND status = 'PUBLISHED'
|
AND status = 'PUBLISHED'
|
||||||
|
|
@ -159,12 +160,13 @@ func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, err
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetPublishedQuestionSetsByOwner = `-- name: GetPublishedQuestionSetsByOwner :many
|
const GetPublishedQuestionSetsByOwner = `-- name: GetPublishedQuestionSetsByOwner :many
|
||||||
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id
|
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order
|
||||||
FROM question_sets
|
FROM question_sets
|
||||||
WHERE owner_type = $1
|
WHERE owner_type = $1
|
||||||
AND owner_id = $2
|
AND owner_id = $2
|
||||||
|
|
@ -202,6 +204,7 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +217,7 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetQuestionSetByID = `-- name: GetQuestionSetByID :one
|
const GetQuestionSetByID = `-- name: GetQuestionSetByID :one
|
||||||
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id
|
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order
|
||||||
FROM question_sets
|
FROM question_sets
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -238,17 +241,18 @@ func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetQuestionSetsByOwner = `-- name: GetQuestionSetsByOwner :many
|
const GetQuestionSetsByOwner = `-- name: GetQuestionSetsByOwner :many
|
||||||
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id
|
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order
|
||||||
FROM question_sets
|
FROM question_sets
|
||||||
WHERE owner_type = $1
|
WHERE owner_type = $1
|
||||||
AND owner_id = $2
|
AND owner_id = $2
|
||||||
AND status != 'ARCHIVED'
|
AND status != 'ARCHIVED'
|
||||||
ORDER BY created_at DESC
|
ORDER BY display_order ASC, created_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetQuestionSetsByOwnerParams struct {
|
type GetQuestionSetsByOwnerParams struct {
|
||||||
|
|
@ -281,6 +285,7 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -295,7 +300,7 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet
|
||||||
const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many
|
const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) OVER () AS total_count,
|
COUNT(*) OVER () AS total_count,
|
||||||
qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id
|
qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order
|
||||||
FROM question_sets qs
|
FROM question_sets qs
|
||||||
WHERE set_type = $1
|
WHERE set_type = $1
|
||||||
AND status != 'ARCHIVED'
|
AND status != 'ARCHIVED'
|
||||||
|
|
@ -327,6 +332,7 @@ type GetQuestionSetsByTypeRow struct {
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
|
SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSetsByTypeParams) ([]GetQuestionSetsByTypeRow, error) {
|
func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSetsByTypeParams) ([]GetQuestionSetsByTypeRow, error) {
|
||||||
|
|
@ -355,6 +361,7 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.SubCourseVideoID,
|
&i.SubCourseVideoID,
|
||||||
|
&i.DisplayOrder,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -435,6 +442,25 @@ func (q *Queries) RemoveUserPersonaFromQuestionSet(ctx context.Context, arg Remo
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ReorderQuestionSets = `-- name: ReorderQuestionSets :exec
|
||||||
|
UPDATE question_sets
|
||||||
|
SET display_order = bulk.position
|
||||||
|
FROM (
|
||||||
|
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
|
||||||
|
) AS bulk
|
||||||
|
WHERE question_sets.id = bulk.id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ReorderQuestionSetsParams struct {
|
||||||
|
Ids []int64 `json:"ids"`
|
||||||
|
Positions []int32 `json:"positions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ReorderQuestionSets(ctx context.Context, arg ReorderQuestionSetsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, ReorderQuestionSets, arg.Ids, arg.Positions)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const UpdateQuestionSet = `-- name: UpdateQuestionSet :exec
|
const UpdateQuestionSet = `-- name: UpdateQuestionSet :exec
|
||||||
UPDATE question_sets
|
UPDATE question_sets
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,11 @@ INSERT INTO questions (
|
||||||
tips,
|
tips,
|
||||||
voice_prompt,
|
voice_prompt,
|
||||||
sample_answer_voice_prompt,
|
sample_answer_voice_prompt,
|
||||||
|
image_url,
|
||||||
status
|
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 id, question_text, question_type, difficulty_level, points, explanation, tips, voice_prompt, sample_answer_voice_prompt, status, created_at, updated_at
|
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 {
|
type CreateQuestionParams struct {
|
||||||
|
|
@ -47,7 +48,8 @@ type CreateQuestionParams struct {
|
||||||
Tips pgtype.Text `json:"tips"`
|
Tips pgtype.Text `json:"tips"`
|
||||||
VoicePrompt pgtype.Text `json:"voice_prompt"`
|
VoicePrompt pgtype.Text `json:"voice_prompt"`
|
||||||
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_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) {
|
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.Tips,
|
||||||
arg.VoicePrompt,
|
arg.VoicePrompt,
|
||||||
arg.SampleAnswerVoicePrompt,
|
arg.SampleAnswerVoicePrompt,
|
||||||
arg.Column9,
|
arg.ImageUrl,
|
||||||
|
arg.Column10,
|
||||||
)
|
)
|
||||||
var i Question
|
var i Question
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
|
|
@ -76,6 +79,7 @@ func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams)
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ImageUrl,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +95,7 @@ func (q *Queries) DeleteQuestion(ctx context.Context, id int64) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetQuestionByID = `-- name: GetQuestionByID :one
|
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
|
FROM questions
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -112,6 +116,7 @@ func (q *Queries) GetQuestionByID(ctx context.Context, id int64) (Question, erro
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ImageUrl,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +193,7 @@ func (q *Queries) GetQuestionWithOptions(ctx context.Context, id int64) ([]GetQu
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetQuestionsByIDs = `-- name: GetQuestionsByIDs :many
|
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
|
FROM questions
|
||||||
WHERE id = ANY($1::BIGINT[])
|
WHERE id = ANY($1::BIGINT[])
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
|
|
@ -216,6 +221,7 @@ func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Qu
|
||||||
&i.Status,
|
&i.Status,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ImageUrl,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -230,7 +236,7 @@ func (q *Queries) GetQuestionsByIDs(ctx context.Context, dollar_1 []int64) ([]Qu
|
||||||
const ListQuestions = `-- name: ListQuestions :many
|
const ListQuestions = `-- name: ListQuestions :many
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) OVER () AS total_count,
|
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
|
FROM questions q
|
||||||
WHERE status != 'ARCHIVED'
|
WHERE status != 'ARCHIVED'
|
||||||
AND ($1::VARCHAR IS NULL OR $1 = '' OR question_type = $1)
|
AND ($1::VARCHAR IS NULL OR $1 = '' OR question_type = $1)
|
||||||
|
|
@ -263,6 +269,7 @@ type ListQuestionsRow struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ImageUrl pgtype.Text `json:"image_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([]ListQuestionsRow, error) {
|
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.Status,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ImageUrl,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +316,7 @@ func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([
|
||||||
const SearchQuestions = `-- name: SearchQuestions :many
|
const SearchQuestions = `-- name: SearchQuestions :many
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) OVER () AS total_count,
|
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
|
FROM questions q
|
||||||
WHERE status != 'ARCHIVED'
|
WHERE status != 'ARCHIVED'
|
||||||
AND question_text ILIKE '%' || $1 || '%'
|
AND question_text ILIKE '%' || $1 || '%'
|
||||||
|
|
@ -337,6 +345,7 @@ type SearchQuestionsRow struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ImageUrl pgtype.Text `json:"image_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SearchQuestions(ctx context.Context, arg SearchQuestionsParams) ([]SearchQuestionsRow, error) {
|
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.Status,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ImageUrl,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -384,9 +394,10 @@ SET
|
||||||
tips = COALESCE($6, tips),
|
tips = COALESCE($6, tips),
|
||||||
voice_prompt = COALESCE($7, voice_prompt),
|
voice_prompt = COALESCE($7, voice_prompt),
|
||||||
sample_answer_voice_prompt = COALESCE($8, sample_answer_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
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $10
|
WHERE id = $11
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateQuestionParams struct {
|
type UpdateQuestionParams struct {
|
||||||
|
|
@ -398,6 +409,7 @@ type UpdateQuestionParams struct {
|
||||||
Tips pgtype.Text `json:"tips"`
|
Tips pgtype.Text `json:"tips"`
|
||||||
VoicePrompt pgtype.Text `json:"voice_prompt"`
|
VoicePrompt pgtype.Text `json:"voice_prompt"`
|
||||||
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
|
SampleAnswerVoicePrompt pgtype.Text `json:"sample_answer_voice_prompt"`
|
||||||
|
ImageUrl pgtype.Text `json:"image_url"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
@ -412,6 +424,7 @@ func (q *Queries) UpdateQuestion(ctx context.Context, arg UpdateQuestionParams)
|
||||||
arg.Tips,
|
arg.Tips,
|
||||||
arg.VoicePrompt,
|
arg.VoicePrompt,
|
||||||
arg.SampleAnswerVoicePrompt,
|
arg.SampleAnswerVoicePrompt,
|
||||||
|
arg.ImageUrl,
|
||||||
arg.Status,
|
arg.Status,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,25 @@ func (q *Queries) PublishSubCourseVideo(ctx context.Context, id int64) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ReorderSubCourseVideos = `-- name: ReorderSubCourseVideos :exec
|
||||||
|
UPDATE sub_course_videos
|
||||||
|
SET display_order = bulk.position
|
||||||
|
FROM (
|
||||||
|
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
|
||||||
|
) AS bulk
|
||||||
|
WHERE sub_course_videos.id = bulk.id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ReorderSubCourseVideosParams struct {
|
||||||
|
Ids []int64 `json:"ids"`
|
||||||
|
Positions []int32 `json:"positions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ReorderSubCourseVideos(ctx context.Context, arg ReorderSubCourseVideosParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, ReorderSubCourseVideos, arg.Ids, arg.Positions)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const UpdateSubCourseVideo = `-- name: UpdateSubCourseVideo :exec
|
const UpdateSubCourseVideo = `-- name: UpdateSubCourseVideo :exec
|
||||||
UPDATE sub_course_videos
|
UPDATE sub_course_videos
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,25 @@ func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ReorderSubCourses = `-- name: ReorderSubCourses :exec
|
||||||
|
UPDATE sub_courses
|
||||||
|
SET display_order = bulk.position
|
||||||
|
FROM (
|
||||||
|
SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position
|
||||||
|
) AS bulk
|
||||||
|
WHERE sub_courses.id = bulk.id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ReorderSubCoursesParams struct {
|
||||||
|
Ids []int64 `json:"ids"`
|
||||||
|
Positions []int32 `json:"positions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ReorderSubCourses(ctx context.Context, arg ReorderSubCoursesParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, ReorderSubCourses, arg.Ids, arg.Positions)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const UpdateSubCourse = `-- name: UpdateSubCourse :exec
|
const UpdateSubCourse = `-- name: UpdateSubCourse :exec
|
||||||
UPDATE sub_courses
|
UPDATE sub_courses
|
||||||
SET
|
SET
|
||||||
|
|
|
||||||
|
|
@ -764,6 +764,27 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetUserSummary = `-- name: GetUserSummary :one
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_users,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'ACTIVE') AS active_users,
|
||||||
|
COUNT(*) FILTER (WHERE created_at >= date_trunc('month', CURRENT_DATE)) AS joined_this_month
|
||||||
|
FROM users
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetUserSummaryRow struct {
|
||||||
|
TotalUsers int64 `json:"total_users"`
|
||||||
|
ActiveUsers int64 `json:"active_users"`
|
||||||
|
JoinedThisMonth int64 `json:"joined_this_month"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetUserSummary(ctx context.Context) (GetUserSummaryRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetUserSummary)
|
||||||
|
var i GetUserSummaryRow
|
||||||
|
err := row.Scan(&i.TotalUsers, &i.ActiveUsers, &i.JoinedThisMonth)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const IsUserNameUnique = `-- name: IsUserNameUnique :one
|
const IsUserNameUnique = `-- name: IsUserNameUnique :one
|
||||||
SELECT
|
SELECT
|
||||||
CASE WHEN COUNT(*) = 0 THEN true ELSE false END AS is_unique
|
CASE WHEN COUNT(*) = 0 THEN true ELSE false END AS is_unique
|
||||||
|
|
|
||||||
|
|
@ -87,3 +87,58 @@ const (
|
||||||
VideoHostProviderDirect VideoHostProvider = "DIRECT"
|
VideoHostProviderDirect VideoHostProvider = "DIRECT"
|
||||||
VideoHostProviderVimeo VideoHostProvider = "VIMEO"
|
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"
|
QuestionTypeMCQ QuestionType = "MCQ"
|
||||||
QuestionTypeTrueFalse QuestionType = "TRUE_FALSE"
|
QuestionTypeTrueFalse QuestionType = "TRUE_FALSE"
|
||||||
QuestionTypeShortAnswer QuestionType = "SHORT_ANSWER"
|
QuestionTypeShortAnswer QuestionType = "SHORT_ANSWER"
|
||||||
|
QuestionTypeAudio QuestionType = "AUDIO"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DifficultyLevel string
|
type DifficultyLevel string
|
||||||
|
|
@ -46,15 +47,24 @@ type Question struct {
|
||||||
Tips *string
|
Tips *string
|
||||||
VoicePrompt *string
|
VoicePrompt *string
|
||||||
SampleAnswerVoicePrompt *string
|
SampleAnswerVoicePrompt *string
|
||||||
|
ImageURL *string
|
||||||
Status string
|
Status string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt *time.Time
|
UpdatedAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QuestionAudioAnswer struct {
|
||||||
|
ID int64
|
||||||
|
QuestionID int64
|
||||||
|
CorrectAnswerText string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type QuestionWithDetails struct {
|
type QuestionWithDetails struct {
|
||||||
Question
|
Question
|
||||||
Options []QuestionOption
|
Options []QuestionOption
|
||||||
ShortAnswers []QuestionShortAnswer
|
ShortAnswers []QuestionShortAnswer
|
||||||
|
AudioAnswer *QuestionAudioAnswer
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuestionOption struct {
|
type QuestionOption struct {
|
||||||
|
|
@ -110,6 +120,7 @@ type QuestionSetItemWithQuestion struct {
|
||||||
Explanation *string
|
Explanation *string
|
||||||
Tips *string
|
Tips *string
|
||||||
VoicePrompt *string
|
VoicePrompt *string
|
||||||
|
ImageURL *string
|
||||||
QuestionStatus string
|
QuestionStatus string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,9 +133,11 @@ type CreateQuestionInput struct {
|
||||||
Tips *string
|
Tips *string
|
||||||
VoicePrompt *string
|
VoicePrompt *string
|
||||||
SampleAnswerVoicePrompt *string
|
SampleAnswerVoicePrompt *string
|
||||||
|
ImageURL *string
|
||||||
Status *string
|
Status *string
|
||||||
Options []CreateQuestionOptionInput
|
Options []CreateQuestionOptionInput
|
||||||
ShortAnswers []CreateShortAnswerInput
|
ShortAnswers []CreateShortAnswerInput
|
||||||
|
AudioCorrectAnswerText *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateQuestionOptionInput struct {
|
type CreateQuestionOptionInput struct {
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,12 @@ type UpdateUserStatusReq struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserSummary struct {
|
||||||
|
TotalUsers int64 `json:"total_users"`
|
||||||
|
ActiveUsers int64 `json:"active_users"`
|
||||||
|
JoinedThisMonth int64 `json:"joined_this_month"`
|
||||||
|
}
|
||||||
|
|
||||||
type UpdateUserReq struct {
|
type UpdateUserReq struct {
|
||||||
UserID int64 `json:"-"`
|
UserID int64 `json:"-"`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,16 @@ type CourseStore interface {
|
||||||
|
|
||||||
// Learning Tree
|
// Learning Tree
|
||||||
GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error)
|
GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error)
|
||||||
|
|
||||||
|
// Learning Path (full nested structure for a course)
|
||||||
|
GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error)
|
||||||
|
|
||||||
|
// Reorder (drag-and-drop support)
|
||||||
|
ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error
|
||||||
|
ReorderCourses(ctx context.Context, ids []int64, positions []int32) error
|
||||||
|
ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error
|
||||||
|
ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error
|
||||||
|
ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProgressionStore interface {
|
type ProgressionStore interface {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ type UserStore interface {
|
||||||
limit, offset int32,
|
limit, offset int32,
|
||||||
) ([]domain.User, int64, error)
|
) ([]domain.User, int64, error)
|
||||||
GetTotalUsers(ctx context.Context, role *string) (int64, error)
|
GetTotalUsers(ctx context.Context, role *string) (int64, error)
|
||||||
|
GetUserSummary(ctx context.Context) (domain.UserSummary, error)
|
||||||
SearchUserByNameOrPhone(
|
SearchUserByNameOrPhone(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
search string,
|
search string,
|
||||||
|
|
|
||||||
|
|
@ -119,3 +119,10 @@ func (s *Store) DeleteCourseCategory(
|
||||||
|
|
||||||
return s.queries.DeleteCourseCategory(ctx, id)
|
return s.queries.DeleteCourseCategory(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error {
|
||||||
|
return s.queries.ReorderCourseCategories(ctx, dbgen.ReorderCourseCategoriesParams{
|
||||||
|
Ids: ids,
|
||||||
|
Positions: positions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,13 @@ func mapCourse(row dbgen.Course) domain.Course {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReorderCourses(ctx context.Context, ids []int64, positions []int32) error {
|
||||||
|
return s.queries.ReorderCourses(ctx, dbgen.ReorderCoursesParams{
|
||||||
|
Ids: ids,
|
||||||
|
Positions: positions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func ptrText(t pgtype.Text) *string {
|
func ptrText(t pgtype.Text) *string {
|
||||||
if t.Valid {
|
if t.Valid {
|
||||||
return &t.String
|
return &t.String
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ package repository
|
||||||
import (
|
import (
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Store) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) {
|
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
|
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),
|
Tips: fromPgText(q.Tips),
|
||||||
VoicePrompt: fromPgText(q.VoicePrompt),
|
VoicePrompt: fromPgText(q.VoicePrompt),
|
||||||
SampleAnswerVoicePrompt: fromPgText(q.SampleAnswerVoicePrompt),
|
SampleAnswerVoicePrompt: fromPgText(q.SampleAnswerVoicePrompt),
|
||||||
|
ImageURL: fromPgText(q.ImageUrl),
|
||||||
Status: q.Status,
|
Status: q.Status,
|
||||||
CreatedAt: q.CreatedAt.Time,
|
CreatedAt: q.CreatedAt.Time,
|
||||||
UpdatedAt: timePtr(q.UpdatedAt),
|
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 {
|
func questionSetToDomain(qs dbgen.QuestionSet) domain.QuestionSet {
|
||||||
return domain.QuestionSet{
|
return domain.QuestionSet{
|
||||||
ID: qs.ID,
|
ID: qs.ID,
|
||||||
|
|
@ -152,7 +162,8 @@ func (s *Store) CreateQuestion(ctx context.Context, input domain.CreateQuestionI
|
||||||
Tips: toPgText(input.Tips),
|
Tips: toPgText(input.Tips),
|
||||||
VoicePrompt: toPgText(input.VoicePrompt),
|
VoicePrompt: toPgText(input.VoicePrompt),
|
||||||
SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt),
|
SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt),
|
||||||
Column9: status,
|
ImageUrl: toPgText(input.ImageURL),
|
||||||
|
Column10: status,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Question{}, err
|
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 {
|
if err = tx.Commit(ctx); err != nil {
|
||||||
return domain.Question{}, err
|
return domain.Question{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -230,10 +251,18 @@ func (s *Store) GetQuestionWithDetails(ctx context.Context, id int64) (domain.Qu
|
||||||
answers[i] = questionShortAnswerToDomain(a)
|
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{
|
return domain.QuestionWithDetails{
|
||||||
Question: questionToDomain(q),
|
Question: questionToDomain(q),
|
||||||
Options: options,
|
Options: options,
|
||||||
ShortAnswers: answers,
|
ShortAnswers: answers,
|
||||||
|
AudioAnswer: audioAnswer,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,6 +305,7 @@ func (s *Store) ListQuestions(ctx context.Context, questionType, difficulty, sta
|
||||||
Tips: fromPgText(r.Tips),
|
Tips: fromPgText(r.Tips),
|
||||||
VoicePrompt: fromPgText(r.VoicePrompt),
|
VoicePrompt: fromPgText(r.VoicePrompt),
|
||||||
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
|
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
|
||||||
|
ImageURL: fromPgText(r.ImageUrl),
|
||||||
Status: r.Status,
|
Status: r.Status,
|
||||||
CreatedAt: r.CreatedAt.Time,
|
CreatedAt: r.CreatedAt.Time,
|
||||||
UpdatedAt: timePtr(r.UpdatedAt),
|
UpdatedAt: timePtr(r.UpdatedAt),
|
||||||
|
|
@ -311,6 +341,7 @@ func (s *Store) SearchQuestions(ctx context.Context, query string, limit, offset
|
||||||
Tips: fromPgText(r.Tips),
|
Tips: fromPgText(r.Tips),
|
||||||
VoicePrompt: fromPgText(r.VoicePrompt),
|
VoicePrompt: fromPgText(r.VoicePrompt),
|
||||||
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
|
SampleAnswerVoicePrompt: fromPgText(r.SampleAnswerVoicePrompt),
|
||||||
|
ImageURL: fromPgText(r.ImageUrl),
|
||||||
Status: r.Status,
|
Status: r.Status,
|
||||||
CreatedAt: r.CreatedAt.Time,
|
CreatedAt: r.CreatedAt.Time,
|
||||||
UpdatedAt: timePtr(r.UpdatedAt),
|
UpdatedAt: timePtr(r.UpdatedAt),
|
||||||
|
|
@ -330,7 +361,7 @@ func (s *Store) UpdateQuestion(ctx context.Context, id int64, input domain.Creat
|
||||||
status = *input.Status
|
status = *input.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.queries.UpdateQuestion(ctx, dbgen.UpdateQuestionParams{
|
err := s.queries.UpdateQuestion(ctx, dbgen.UpdateQuestionParams{
|
||||||
ID: id,
|
ID: id,
|
||||||
QuestionText: input.QuestionText,
|
QuestionText: input.QuestionText,
|
||||||
QuestionType: input.QuestionType,
|
QuestionType: input.QuestionType,
|
||||||
|
|
@ -340,8 +371,27 @@ func (s *Store) UpdateQuestion(ctx context.Context, id int64, input domain.Creat
|
||||||
Tips: toPgText(input.Tips),
|
Tips: toPgText(input.Tips),
|
||||||
VoicePrompt: toPgText(input.VoicePrompt),
|
VoicePrompt: toPgText(input.VoicePrompt),
|
||||||
SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt),
|
SampleAnswerVoicePrompt: toPgText(input.SampleAnswerVoicePrompt),
|
||||||
|
ImageUrl: toPgText(input.ImageURL),
|
||||||
Status: status,
|
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 {
|
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),
|
Explanation: fromPgText(r.Explanation),
|
||||||
Tips: fromPgText(r.Tips),
|
Tips: fromPgText(r.Tips),
|
||||||
VoicePrompt: fromPgText(r.VoicePrompt),
|
VoicePrompt: fromPgText(r.VoicePrompt),
|
||||||
|
ImageURL: fromPgText(r.ImageUrl),
|
||||||
QuestionStatus: r.QuestionStatus,
|
QuestionStatus: r.QuestionStatus,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -681,6 +732,7 @@ func (s *Store) GetPublishedQuestionsInSet(ctx context.Context, setID int64) ([]
|
||||||
Explanation: fromPgText(r.Explanation),
|
Explanation: fromPgText(r.Explanation),
|
||||||
Tips: fromPgText(r.Tips),
|
Tips: fromPgText(r.Tips),
|
||||||
VoicePrompt: fromPgText(r.VoicePrompt),
|
VoicePrompt: fromPgText(r.VoicePrompt),
|
||||||
|
ImageURL: fromPgText(r.ImageUrl),
|
||||||
QuestionStatus: "PUBLISHED",
|
QuestionStatus: "PUBLISHED",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -757,3 +809,10 @@ func (s *Store) GetUserPersonasByQuestionSetID(ctx context.Context, questionSetI
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error {
|
||||||
|
return s.queries.ReorderQuestionSets(ctx, dbgen.ReorderQuestionSetsParams{
|
||||||
|
Ids: ids,
|
||||||
|
Positions: positions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,3 +283,10 @@ func (s *Store) GetVideoByVimeoID(ctx context.Context, vimeoID string) (domain.S
|
||||||
}
|
}
|
||||||
return mapSubCourseVideoRow(row), nil
|
return mapSubCourseVideoRow(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error {
|
||||||
|
return s.queries.ReorderSubCourseVideos(ctx, dbgen.ReorderSubCourseVideosParams{
|
||||||
|
Ids: ids,
|
||||||
|
Positions: positions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -233,4 +233,11 @@ func (s *Store) DeleteSubCourse(
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error {
|
||||||
|
return s.queries.ReorderSubCourses(ctx, dbgen.ReorderSubCoursesParams{
|
||||||
|
Ids: ids,
|
||||||
|
Positions: positions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -515,6 +515,19 @@ func (s *Store) GetAllUsers(
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTotalUsers counts users with optional filters
|
// GetTotalUsers counts users with optional filters
|
||||||
|
func (s *Store) GetUserSummary(ctx context.Context) (domain.UserSummary, error) {
|
||||||
|
res, err := s.queries.GetUserSummary(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.UserSummary{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.UserSummary{
|
||||||
|
TotalUsers: res.TotalUsers,
|
||||||
|
ActiveUsers: res.ActiveUsers,
|
||||||
|
JoinedThisMonth: res.JoinedThisMonth,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetTotalUsers(ctx context.Context, role *string) (int64, error) {
|
func (s *Store) GetTotalUsers(ctx context.Context, role *string) (int64, error) {
|
||||||
count, err := s.queries.GetTotalUsers(ctx, *role)
|
count, err := s.queries.GetTotalUsers(ctx, *role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,27 @@ import (
|
||||||
func (s *Service) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) {
|
func (s *Service) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) {
|
||||||
return s.courseStore.GetFullLearningTree(ctx)
|
return s.courseStore.GetFullLearningTree(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) {
|
||||||
|
return s.courseStore.GetCourseLearningPath(ctx, courseID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error {
|
||||||
|
return s.courseStore.ReorderCourseCategories(ctx, ids, positions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReorderCourses(ctx context.Context, ids []int64, positions []int32) error {
|
||||||
|
return s.courseStore.ReorderCourses(ctx, ids, positions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error {
|
||||||
|
return s.courseStore.ReorderSubCourses(ctx, ids, positions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error {
|
||||||
|
return s.courseStore.ReorderSubCourseVideos(ctx, ids, positions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error {
|
||||||
|
return s.courseStore.ReorderQuestionSets(ctx, ids, positions)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ var AllPermissions = []domain.PermissionSeed{
|
||||||
{Key: "course_categories.get", Name: "Get Course Category", Description: "Get a course category by ID", GroupName: "Course Categories"},
|
{Key: "course_categories.get", Name: "Get Course Category", Description: "Get a course category by ID", GroupName: "Course Categories"},
|
||||||
{Key: "course_categories.update", Name: "Update Course Category", Description: "Update a course category", GroupName: "Course Categories"},
|
{Key: "course_categories.update", Name: "Update Course Category", Description: "Update a course category", GroupName: "Course Categories"},
|
||||||
{Key: "course_categories.delete", Name: "Delete Course Category", Description: "Delete a course category", GroupName: "Course Categories"},
|
{Key: "course_categories.delete", Name: "Delete Course Category", Description: "Delete a course category", GroupName: "Course Categories"},
|
||||||
|
{Key: "course_categories.reorder", Name: "Reorder Course Categories", Description: "Reorder course categories", GroupName: "Course Categories"},
|
||||||
|
|
||||||
// Course Management - Courses
|
// Course Management - Courses
|
||||||
{Key: "courses.create", Name: "Create Course", Description: "Create a new course", GroupName: "Courses"},
|
{Key: "courses.create", Name: "Create Course", Description: "Create a new course", GroupName: "Courses"},
|
||||||
|
|
@ -17,6 +18,7 @@ var AllPermissions = []domain.PermissionSeed{
|
||||||
{Key: "courses.update", Name: "Update Course", Description: "Update a course", GroupName: "Courses"},
|
{Key: "courses.update", Name: "Update Course", Description: "Update a course", GroupName: "Courses"},
|
||||||
{Key: "courses.upload_thumbnail", Name: "Upload Course Thumbnail", Description: "Upload course thumbnail image", GroupName: "Courses"},
|
{Key: "courses.upload_thumbnail", Name: "Upload Course Thumbnail", Description: "Upload course thumbnail image", GroupName: "Courses"},
|
||||||
{Key: "courses.delete", Name: "Delete Course", Description: "Delete a course", GroupName: "Courses"},
|
{Key: "courses.delete", Name: "Delete Course", Description: "Delete a course", GroupName: "Courses"},
|
||||||
|
{Key: "courses.reorder", Name: "Reorder Courses", Description: "Reorder courses", GroupName: "Courses"},
|
||||||
|
|
||||||
// Course Management - Sub-courses
|
// Course Management - Sub-courses
|
||||||
{Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"},
|
{Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"},
|
||||||
|
|
@ -28,6 +30,7 @@ var AllPermissions = []domain.PermissionSeed{
|
||||||
{Key: "subcourses.upload_thumbnail", Name: "Upload Sub-course Thumbnail", Description: "Upload sub-course thumbnail", GroupName: "Sub-courses"},
|
{Key: "subcourses.upload_thumbnail", Name: "Upload Sub-course Thumbnail", Description: "Upload sub-course thumbnail", GroupName: "Sub-courses"},
|
||||||
{Key: "subcourses.deactivate", Name: "Deactivate Sub-course", Description: "Deactivate a sub-course", GroupName: "Sub-courses"},
|
{Key: "subcourses.deactivate", Name: "Deactivate Sub-course", Description: "Deactivate a sub-course", GroupName: "Sub-courses"},
|
||||||
{Key: "subcourses.delete", Name: "Delete Sub-course", Description: "Delete a sub-course", GroupName: "Sub-courses"},
|
{Key: "subcourses.delete", Name: "Delete Sub-course", Description: "Delete a sub-course", GroupName: "Sub-courses"},
|
||||||
|
{Key: "subcourses.reorder", Name: "Reorder Sub-courses", Description: "Reorder sub-courses", GroupName: "Sub-courses"},
|
||||||
|
|
||||||
// Course Management - Videos
|
// Course Management - Videos
|
||||||
{Key: "videos.create", Name: "Create Video", Description: "Create a sub-course video", GroupName: "Videos"},
|
{Key: "videos.create", Name: "Create Video", Description: "Create a sub-course video", GroupName: "Videos"},
|
||||||
|
|
@ -40,9 +43,11 @@ var AllPermissions = []domain.PermissionSeed{
|
||||||
{Key: "videos.publish", Name: "Publish Video", Description: "Publish a video", GroupName: "Videos"},
|
{Key: "videos.publish", Name: "Publish Video", Description: "Publish a video", GroupName: "Videos"},
|
||||||
{Key: "videos.update", Name: "Update Video", Description: "Update a video", GroupName: "Videos"},
|
{Key: "videos.update", Name: "Update Video", Description: "Update a video", GroupName: "Videos"},
|
||||||
{Key: "videos.delete", Name: "Delete Video", Description: "Delete a video", GroupName: "Videos"},
|
{Key: "videos.delete", Name: "Delete Video", Description: "Delete a video", GroupName: "Videos"},
|
||||||
|
{Key: "videos.reorder", Name: "Reorder Videos", Description: "Reorder videos", GroupName: "Videos"},
|
||||||
|
|
||||||
// Learning Tree
|
// Learning Tree
|
||||||
{Key: "learning_tree.get", Name: "Get Learning Tree", Description: "Get full learning tree", GroupName: "Learning Tree"},
|
{Key: "learning_tree.get", Name: "Get Learning Tree", Description: "Get full learning tree", GroupName: "Learning Tree"},
|
||||||
|
{Key: "practices.reorder", Name: "Reorder Practices", Description: "Reorder practices", GroupName: "Learning Tree"},
|
||||||
|
|
||||||
// Questions
|
// Questions
|
||||||
{Key: "questions.create", Name: "Create Question", Description: "Create a new question", GroupName: "Questions"},
|
{Key: "questions.create", Name: "Create Question", Description: "Create a new question", GroupName: "Questions"},
|
||||||
|
|
@ -222,13 +227,13 @@ var AllPermissions = []domain.PermissionSeed{
|
||||||
var DefaultRolePermissions = map[string][]string{
|
var DefaultRolePermissions = map[string][]string{
|
||||||
"ADMIN": {
|
"ADMIN": {
|
||||||
// Course Management (full access)
|
// Course Management (full access)
|
||||||
"course_categories.create", "course_categories.list", "course_categories.get", "course_categories.update", "course_categories.delete",
|
"course_categories.create", "course_categories.list", "course_categories.get", "course_categories.update", "course_categories.delete", "course_categories.reorder",
|
||||||
"courses.create", "courses.get", "courses.list_by_category", "courses.update", "courses.upload_thumbnail", "courses.delete",
|
"courses.create", "courses.get", "courses.list_by_category", "courses.update", "courses.upload_thumbnail", "courses.delete", "courses.reorder",
|
||||||
"subcourses.create", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
|
"subcourses.create", "subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
|
||||||
"subcourses.update", "subcourses.upload_thumbnail", "subcourses.deactivate", "subcourses.delete",
|
"subcourses.update", "subcourses.upload_thumbnail", "subcourses.deactivate", "subcourses.delete", "subcourses.reorder",
|
||||||
"videos.create", "videos.create_vimeo", "videos.upload", "videos.import_vimeo", "videos.get",
|
"videos.create", "videos.create_vimeo", "videos.upload", "videos.import_vimeo", "videos.get",
|
||||||
"videos.list_by_subcourse", "videos.list_published", "videos.publish", "videos.update", "videos.delete",
|
"videos.list_by_subcourse", "videos.list_published", "videos.publish", "videos.update", "videos.delete", "videos.reorder",
|
||||||
"learning_tree.get",
|
"learning_tree.get", "practices.reorder",
|
||||||
|
|
||||||
// Questions (full access)
|
// Questions (full access)
|
||||||
"questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete",
|
"questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete",
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,10 @@ func (s *Service) GetAllUsers(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetUserSummary(ctx context.Context) (domain.UserSummary, error) {
|
||||||
|
return s.userStore.GetUserSummary(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error {
|
func (s *Service) UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error {
|
||||||
return s.userStore.UpdateUserStatus(ctx, req)
|
return s.userStore.UpdateUserStatus(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,3 +195,19 @@ func (s *Service) GetOEmbed(ctx context.Context, vimeoURL string, width, height
|
||||||
func (s *Service) GeneratePlayerURL(videoID string, opts *vimeo.EmbedOptions) string {
|
func (s *Service) GeneratePlayerURL(videoID string, opts *vimeo.EmbedOptions) string {
|
||||||
return vimeo.GenerateEmbedURL(videoID, opts)
|
return vimeo.GenerateEmbedURL(videoID, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSampleVideo fetches a public Vimeo video by ID and returns its info along with an embeddable iframe.
|
||||||
|
func (s *Service) GetSampleVideo(ctx context.Context, videoID string, width, height int) (*VideoInfo, string, error) {
|
||||||
|
info, err := s.GetVideoInfo(ctx, videoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to get sample video: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe := vimeo.GenerateIframeEmbed(videoID, width, height, &vimeo.EmbedOptions{
|
||||||
|
Title: true,
|
||||||
|
Byline: true,
|
||||||
|
Portrait: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return info, iframe, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1420,6 +1420,292 @@ 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder Handlers — support drag-and-drop ordering from admin panel
|
||||||
|
|
||||||
|
type reorderItem struct {
|
||||||
|
ID int64 `json:"id" validate:"required"`
|
||||||
|
Position int32 `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reorderReq struct {
|
||||||
|
Items []reorderItem `json:"items" validate:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseReorderItems(items []reorderItem) ([]int64, []int32) {
|
||||||
|
ids := make([]int64, len(items))
|
||||||
|
positions := make([]int32, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
ids[i] = item.ID
|
||||||
|
positions[i] = item.Position
|
||||||
|
}
|
||||||
|
return ids, positions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderCourseCategories godoc
|
||||||
|
// @Summary Reorder course categories
|
||||||
|
// @Description Updates the display_order of course categories for drag-and-drop sorting
|
||||||
|
// @Tags course-categories
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body reorderReq true "Reorder payload"
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 400 {object} domain.ErrorResponse
|
||||||
|
// @Failure 500 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/course-management/categories/reorder [put]
|
||||||
|
func (h *Handler) ReorderCourseCategories(c *fiber.Ctx) error {
|
||||||
|
var req reorderReq
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Items array is required",
|
||||||
|
Error: "items must not be empty",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, positions := parseReorderItems(req.Items)
|
||||||
|
if err := h.courseMgmtSvc.ReorderCourseCategories(c.Context(), ids, positions); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to reorder course categories",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
actorID := c.Locals("user_id").(int64)
|
||||||
|
actorRole := string(c.Locals("role").(domain.Role))
|
||||||
|
ip := c.IP()
|
||||||
|
ua := c.Get("User-Agent")
|
||||||
|
meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions})
|
||||||
|
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryUpdated, domain.ResourceCategory, nil, "Reordered course categories", meta, &ip, &ua)
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Course categories reordered successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderCourses godoc
|
||||||
|
// @Summary Reorder courses within a category
|
||||||
|
// @Description Updates the display_order of courses for drag-and-drop sorting
|
||||||
|
// @Tags courses
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body reorderReq true "Reorder payload"
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 400 {object} domain.ErrorResponse
|
||||||
|
// @Failure 500 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/course-management/courses/reorder [put]
|
||||||
|
func (h *Handler) ReorderCourses(c *fiber.Ctx) error {
|
||||||
|
var req reorderReq
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Items array is required",
|
||||||
|
Error: "items must not be empty",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, positions := parseReorderItems(req.Items)
|
||||||
|
if err := h.courseMgmtSvc.ReorderCourses(c.Context(), ids, positions); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to reorder courses",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
actorID := c.Locals("user_id").(int64)
|
||||||
|
actorRole := string(c.Locals("role").(domain.Role))
|
||||||
|
ip := c.IP()
|
||||||
|
ua := c.Get("User-Agent")
|
||||||
|
meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions})
|
||||||
|
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceCourse, nil, "Reordered courses", meta, &ip, &ua)
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Courses reordered successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderSubCourses godoc
|
||||||
|
// @Summary Reorder sub-courses within a course
|
||||||
|
// @Description Updates the display_order of sub-courses for drag-and-drop sorting
|
||||||
|
// @Tags sub-courses
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body reorderReq true "Reorder payload"
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 400 {object} domain.ErrorResponse
|
||||||
|
// @Failure 500 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/course-management/sub-courses/reorder [put]
|
||||||
|
func (h *Handler) ReorderSubCourses(c *fiber.Ctx) error {
|
||||||
|
var req reorderReq
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Items array is required",
|
||||||
|
Error: "items must not be empty",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, positions := parseReorderItems(req.Items)
|
||||||
|
if err := h.courseMgmtSvc.ReorderSubCourses(c.Context(), ids, positions); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to reorder sub-courses",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
actorID := c.Locals("user_id").(int64)
|
||||||
|
actorRole := string(c.Locals("role").(domain.Role))
|
||||||
|
ip := c.IP()
|
||||||
|
ua := c.Get("User-Agent")
|
||||||
|
meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions})
|
||||||
|
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseUpdated, domain.ResourceSubCourse, nil, "Reordered sub-courses", meta, &ip, &ua)
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Sub-courses reordered successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderSubCourseVideos godoc
|
||||||
|
// @Summary Reorder videos within a sub-course
|
||||||
|
// @Description Updates the display_order of videos for drag-and-drop sorting
|
||||||
|
// @Tags sub-course-videos
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body reorderReq true "Reorder payload"
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 400 {object} domain.ErrorResponse
|
||||||
|
// @Failure 500 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/course-management/videos/reorder [put]
|
||||||
|
func (h *Handler) ReorderSubCourseVideos(c *fiber.Ctx) error {
|
||||||
|
var req reorderReq
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Items array is required",
|
||||||
|
Error: "items must not be empty",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, positions := parseReorderItems(req.Items)
|
||||||
|
if err := h.courseMgmtSvc.ReorderSubCourseVideos(c.Context(), ids, positions); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to reorder videos",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
actorID := c.Locals("user_id").(int64)
|
||||||
|
actorRole := string(c.Locals("role").(domain.Role))
|
||||||
|
ip := c.IP()
|
||||||
|
ua := c.Get("User-Agent")
|
||||||
|
meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions})
|
||||||
|
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoUpdated, domain.ResourceVideo, nil, "Reordered sub-course videos", meta, &ip, &ua)
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Videos reordered successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderPractices godoc
|
||||||
|
// @Summary Reorder practices (question sets) within a sub-course
|
||||||
|
// @Description Updates the display_order of practices for drag-and-drop sorting
|
||||||
|
// @Tags question-sets
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body reorderReq true "Reorder payload"
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 400 {object} domain.ErrorResponse
|
||||||
|
// @Failure 500 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/course-management/practices/reorder [put]
|
||||||
|
func (h *Handler) ReorderPractices(c *fiber.Ctx) error {
|
||||||
|
var req reorderReq
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Items array is required",
|
||||||
|
Error: "items must not be empty",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, positions := parseReorderItems(req.Items)
|
||||||
|
if err := h.courseMgmtSvc.ReorderQuestionSets(c.Context(), ids, positions); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to reorder practices",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
actorID := c.Locals("user_id").(int64)
|
||||||
|
actorRole := string(c.Locals("role").(domain.Role))
|
||||||
|
ip := c.IP()
|
||||||
|
ua := c.Get("User-Agent")
|
||||||
|
meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions})
|
||||||
|
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, nil, "Reordered practices", meta, &ip, &ua)
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Practices reordered successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// UploadSubCourseVideo godoc
|
// UploadSubCourseVideo godoc
|
||||||
// @Summary Upload a video file and create sub-course video
|
// @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
|
// @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 {
|
type createQuestionReq struct {
|
||||||
QuestionText string `json:"question_text" validate:"required"`
|
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"`
|
DifficultyLevel *string `json:"difficulty_level"`
|
||||||
Points *int32 `json:"points"`
|
Points *int32 `json:"points"`
|
||||||
Explanation *string `json:"explanation"`
|
Explanation *string `json:"explanation"`
|
||||||
Tips *string `json:"tips"`
|
Tips *string `json:"tips"`
|
||||||
VoicePrompt *string `json:"voice_prompt"`
|
VoicePrompt *string `json:"voice_prompt"`
|
||||||
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
|
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
|
||||||
|
ImageURL *string `json:"image_url"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
Options []optionInput `json:"options"`
|
Options []optionInput `json:"options"`
|
||||||
ShortAnswers []shortAnswerInput `json:"short_answers"`
|
ShortAnswers []shortAnswerInput `json:"short_answers"`
|
||||||
|
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type optionRes struct {
|
type optionRes struct {
|
||||||
|
|
@ -60,10 +62,12 @@ type questionRes struct {
|
||||||
Tips *string `json:"tips,omitempty"`
|
Tips *string `json:"tips,omitempty"`
|
||||||
VoicePrompt *string `json:"voice_prompt,omitempty"`
|
VoicePrompt *string `json:"voice_prompt,omitempty"`
|
||||||
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
|
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
|
||||||
|
ImageURL *string `json:"image_url,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
Options []optionRes `json:"options,omitempty"`
|
Options []optionRes `json:"options,omitempty"`
|
||||||
ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"`
|
ShortAnswers []shortAnswerRes `json:"short_answers,omitempty"`
|
||||||
|
AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type listQuestionsRes struct {
|
type listQuestionsRes struct {
|
||||||
|
|
@ -119,9 +123,11 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
|
||||||
Tips: req.Tips,
|
Tips: req.Tips,
|
||||||
VoicePrompt: req.VoicePrompt,
|
VoicePrompt: req.VoicePrompt,
|
||||||
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
|
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
|
||||||
|
ImageURL: req.ImageURL,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
Options: options,
|
Options: options,
|
||||||
ShortAnswers: shortAnswers,
|
ShortAnswers: shortAnswers,
|
||||||
|
AudioCorrectAnswerText: req.AudioCorrectAnswerText,
|
||||||
}
|
}
|
||||||
|
|
||||||
question, err := h.questionsSvc.CreateQuestion(c.Context(), input)
|
question, err := h.questionsSvc.CreateQuestion(c.Context(), input)
|
||||||
|
|
@ -151,6 +157,7 @@ func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
|
||||||
Tips: question.Tips,
|
Tips: question.Tips,
|
||||||
VoicePrompt: question.VoicePrompt,
|
VoicePrompt: question.VoicePrompt,
|
||||||
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
|
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
|
||||||
|
ImageURL: question.ImageURL,
|
||||||
Status: question.Status,
|
Status: question.Status,
|
||||||
CreatedAt: question.CreatedAt.String(),
|
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{
|
return c.JSON(domain.Response{
|
||||||
Message: "Question retrieved successfully",
|
Message: "Question retrieved successfully",
|
||||||
Data: questionRes{
|
Data: questionRes{
|
||||||
|
|
@ -216,10 +228,12 @@ func (h *Handler) GetQuestionByID(c *fiber.Ctx) error {
|
||||||
Tips: question.Tips,
|
Tips: question.Tips,
|
||||||
VoicePrompt: question.VoicePrompt,
|
VoicePrompt: question.VoicePrompt,
|
||||||
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
|
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
|
||||||
|
ImageURL: question.ImageURL,
|
||||||
Status: question.Status,
|
Status: question.Status,
|
||||||
CreatedAt: question.CreatedAt.String(),
|
CreatedAt: question.CreatedAt.String(),
|
||||||
Options: options,
|
Options: options,
|
||||||
ShortAnswers: shortAnswers,
|
ShortAnswers: shortAnswers,
|
||||||
|
AudioCorrectAnswerText: audioCorrectAnswerText,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -356,9 +370,11 @@ type updateQuestionReq struct {
|
||||||
Tips *string `json:"tips"`
|
Tips *string `json:"tips"`
|
||||||
VoicePrompt *string `json:"voice_prompt"`
|
VoicePrompt *string `json:"voice_prompt"`
|
||||||
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
|
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt"`
|
||||||
|
ImageURL *string `json:"image_url"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
Options []optionInput `json:"options"`
|
Options []optionInput `json:"options"`
|
||||||
ShortAnswers []shortAnswerInput `json:"short_answers"`
|
ShortAnswers []shortAnswerInput `json:"short_answers"`
|
||||||
|
AudioCorrectAnswerText *string `json:"audio_correct_answer_text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateQuestion godoc
|
// UpdateQuestion godoc
|
||||||
|
|
@ -426,9 +442,11 @@ func (h *Handler) UpdateQuestion(c *fiber.Ctx) error {
|
||||||
Tips: req.Tips,
|
Tips: req.Tips,
|
||||||
VoicePrompt: req.VoicePrompt,
|
VoicePrompt: req.VoicePrompt,
|
||||||
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
|
SampleAnswerVoicePrompt: req.SampleAnswerVoicePrompt,
|
||||||
|
ImageURL: req.ImageURL,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
Options: options,
|
Options: options,
|
||||||
ShortAnswers: shortAnswers,
|
ShortAnswers: shortAnswers,
|
||||||
|
AudioCorrectAnswerText: req.AudioCorrectAnswerText,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.questionsSvc.UpdateQuestion(c.Context(), id, input)
|
err = h.questionsSvc.UpdateQuestion(c.Context(), id, input)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,33 @@ import (
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GetUserSummary godoc
|
||||||
|
// @Summary Get user summary statistics
|
||||||
|
// @Description Returns total users, active users, and users who joined this month
|
||||||
|
// @Tags user
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security Bearer
|
||||||
|
// @Success 200 {object} domain.Response{data=domain.UserSummary}
|
||||||
|
// @Failure 500 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/users/summary [get]
|
||||||
|
func (h *Handler) GetUserSummary(c *fiber.Ctx) error {
|
||||||
|
summary, err := h.userSvc.GetUserSummary(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to get user summary",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||||
|
Message: "User summary retrieved successfully",
|
||||||
|
Data: summary,
|
||||||
|
Success: true,
|
||||||
|
StatusCode: fiber.StatusOK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// CheckProfileCompleted godoc
|
// CheckProfileCompleted godoc
|
||||||
// @Summary Check if user profile is completed
|
// @Summary Check if user profile is completed
|
||||||
// @Description Returns the profile completion status and percentage for the specified user
|
// @Description Returns the profile completion status and percentage for the specified user
|
||||||
|
|
|
||||||
|
|
@ -378,6 +378,63 @@ func (h *Handler) GetTranscodeStatus(c *fiber.Ctx) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSampleVideo godoc
|
||||||
|
// @Summary Get a sample Vimeo video with iframe embed
|
||||||
|
// @Description Fetches a sample video from Vimeo and returns video details along with an embeddable iframe for client-side integration
|
||||||
|
// @Tags Vimeo
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param video_id query string false "Vimeo Video ID to use as sample" default(76979871)
|
||||||
|
// @Param width query int false "Player width" default(640)
|
||||||
|
// @Param height query int false "Player height" default(360)
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 500 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/vimeo/sample [get]
|
||||||
|
func (h *Handler) GetSampleVideo(c *fiber.Ctx) error {
|
||||||
|
if h.vimeoSvc == nil {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Vimeo service is not configured",
|
||||||
|
Error: "Vimeo service is not enabled or missing access token",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
videoID := c.Query("video_id", "76979871")
|
||||||
|
width, _ := strconv.Atoi(c.Query("width", "640"))
|
||||||
|
height, _ := strconv.Atoi(c.Query("height", "360"))
|
||||||
|
|
||||||
|
info, iframe, err := h.vimeoSvc.GetSampleVideo(c.Context(), videoID, width, height)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Failed to get sample video",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Sample video retrieved successfully",
|
||||||
|
Data: fiber.Map{
|
||||||
|
"video": VimeoVideoResponse{
|
||||||
|
VimeoID: info.VimeoID,
|
||||||
|
URI: info.URI,
|
||||||
|
Name: info.Name,
|
||||||
|
Description: info.Description,
|
||||||
|
Duration: info.Duration,
|
||||||
|
Width: info.Width,
|
||||||
|
Height: info.Height,
|
||||||
|
Link: info.Link,
|
||||||
|
EmbedURL: info.EmbedURL,
|
||||||
|
EmbedHTML: info.EmbedHTML,
|
||||||
|
ThumbnailURL: info.ThumbnailURL,
|
||||||
|
Status: info.Status,
|
||||||
|
TranscodeStatus: info.TranscodeStatus,
|
||||||
|
},
|
||||||
|
"iframe": iframe,
|
||||||
|
},
|
||||||
|
Success: true,
|
||||||
|
StatusCode: fiber.StatusOK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GetOEmbed godoc
|
// GetOEmbed godoc
|
||||||
// @Summary Get oEmbed data for a Vimeo URL
|
// @Summary Get oEmbed data for a Vimeo URL
|
||||||
// @Description Fetches oEmbed metadata for a Vimeo video URL
|
// @Description Fetches oEmbed metadata for a Vimeo video URL
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,15 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Get("/assessment/questions", h.ListAssessmentQuestions)
|
groupV1.Get("/assessment/questions", h.ListAssessmentQuestions)
|
||||||
groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID)
|
groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID)
|
||||||
|
|
||||||
|
// Reorder (drag-and-drop support)
|
||||||
|
// Keep static reorder routes before dynamic `/:id` routes to avoid route collisions
|
||||||
|
// (e.g., `/courses/reorder` being parsed as `/courses/:id`).
|
||||||
|
groupV1.Put("/course-management/categories/reorder", a.authMiddleware, a.RequirePermission("course_categories.reorder"), h.ReorderCourseCategories)
|
||||||
|
groupV1.Put("/course-management/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCourses)
|
||||||
|
groupV1.Put("/course-management/sub-courses/reorder", a.authMiddleware, a.RequirePermission("subcourses.reorder"), h.ReorderSubCourses)
|
||||||
|
groupV1.Put("/course-management/videos/reorder", a.authMiddleware, a.RequirePermission("videos.reorder"), h.ReorderSubCourseVideos)
|
||||||
|
groupV1.Put("/course-management/practices/reorder", a.authMiddleware, a.RequirePermission("practices.reorder"), h.ReorderPractices)
|
||||||
|
|
||||||
// Course Categories
|
// Course Categories
|
||||||
groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory)
|
groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory)
|
||||||
groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.list"), h.GetAllCourseCategories)
|
groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.list"), h.GetAllCourseCategories)
|
||||||
|
|
@ -111,6 +120,7 @@ func (a *App) initAppRoutes() {
|
||||||
|
|
||||||
// Learning Tree
|
// Learning Tree
|
||||||
groupV1.Get("/course-management/learning-tree", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetFullLearningTree)
|
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
|
// Questions
|
||||||
groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion)
|
groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion)
|
||||||
|
|
@ -208,6 +218,7 @@ func (a *App) initAppRoutes() {
|
||||||
// User Routes
|
// User Routes
|
||||||
groupV1.Get("/user/:user_id/is-profile-completed", a.authMiddleware, a.RequirePermission("users.profile_completed"), h.CheckProfileCompleted)
|
groupV1.Get("/user/:user_id/is-profile-completed", a.authMiddleware, a.RequirePermission("users.profile_completed"), h.CheckProfileCompleted)
|
||||||
groupV1.Get("/users", a.authMiddleware, a.RequirePermission("users.list"), h.GetAllUsers)
|
groupV1.Get("/users", a.authMiddleware, a.RequirePermission("users.list"), h.GetAllUsers)
|
||||||
|
groupV1.Get("/users/summary", a.authMiddleware, a.RequirePermission("users.summary"), h.GetUserSummary)
|
||||||
groupV1.Put("/user", a.authMiddleware, a.RequirePermission("users.update_self"), h.UpdateUser)
|
groupV1.Put("/user", a.authMiddleware, a.RequirePermission("users.update_self"), h.UpdateUser)
|
||||||
groupV1.Patch("/user/status", a.authMiddleware, a.RequirePermission("users.update_status"), h.UpdateUserStatus)
|
groupV1.Patch("/user/status", a.authMiddleware, a.RequirePermission("users.update_status"), h.UpdateUserStatus)
|
||||||
groupV1.Put("/user/knowledge-level", h.UpdateUserKnowledgeLevel)
|
groupV1.Put("/user/knowledge-level", h.UpdateUserKnowledgeLevel)
|
||||||
|
|
@ -291,6 +302,7 @@ func (a *App) initAppRoutes() {
|
||||||
vimeoGroup.Post("/uploads/pull", a.authMiddleware, a.RequirePermission("vimeo.uploads.pull"), h.CreatePullUpload)
|
vimeoGroup.Post("/uploads/pull", a.authMiddleware, a.RequirePermission("vimeo.uploads.pull"), h.CreatePullUpload)
|
||||||
vimeoGroup.Post("/uploads/tus", a.authMiddleware, a.RequirePermission("vimeo.uploads.tus"), h.CreateTusUpload)
|
vimeoGroup.Post("/uploads/tus", a.authMiddleware, a.RequirePermission("vimeo.uploads.tus"), h.CreateTusUpload)
|
||||||
vimeoGroup.Get("/oembed", h.GetOEmbed)
|
vimeoGroup.Get("/oembed", h.GetOEmbed)
|
||||||
|
vimeoGroup.Get("/sample", h.GetSampleVideo)
|
||||||
|
|
||||||
// Team Management
|
// Team Management
|
||||||
teamGroup := groupV1.Group("/team")
|
teamGroup := groupV1.Group("/team")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user