diff --git a/db/data/001_initial_seed_data.sql b/db/data/001_initial_seed_data.sql index a2d7f63..6e987a8 100644 --- a/db/data/001_initial_seed_data.sql +++ b/db/data/001_initial_seed_data.sql @@ -329,37 +329,37 @@ INSERT INTO courses (category_id, title, description, thumbnail, is_active) VALU (3, 'React.js Masterclass', 'Build dynamic user interfaces with React', 'https://example.com/thumbnails/react.jpg', TRUE); -- Sub-courses (replacing Programs/Levels hierarchy) -INSERT INTO sub_courses (course_id, title, description, thumbnail, display_order, level, is_active) VALUES +INSERT INTO sub_courses (course_id, title, description, thumbnail, display_order, level, sub_level, is_active) VALUES -- Python Programming Fundamentals sub-courses -(1, 'Python Basics - Getting Started', 'Introduction to Python and basic syntax', NULL, 1, 'BEGINNER', TRUE), -(1, 'Python Basics - Data Types', 'Understanding Python data types and variables', NULL, 2, 'BEGINNER', TRUE), -(1, 'Python Intermediate - Functions', 'Writing and using functions in Python', NULL, 3, 'INTERMEDIATE', TRUE), -(1, 'Python Intermediate - Collections', 'Working with Python collections', NULL, 4, 'INTERMEDIATE', TRUE), -(1, 'Python Advanced - Best Practices', 'Advanced Python concepts and best practices', NULL, 5, 'ADVANCED', TRUE), +(1, 'Python Basics - Getting Started', 'Introduction to Python and basic syntax', NULL, 1, 'BEGINNER', 'A1', TRUE), +(1, 'Python Basics - Data Types', 'Understanding Python data types and variables', NULL, 2, 'BEGINNER', 'A2', TRUE), +(1, 'Python Intermediate - Functions', 'Writing and using functions in Python', NULL, 3, 'INTERMEDIATE', 'B1', TRUE), +(1, 'Python Intermediate - Collections', 'Working with Python collections', NULL, 4, 'INTERMEDIATE', 'B2', TRUE), +(1, 'Python Advanced - Best Practices', 'Advanced Python concepts and best practices', NULL, 5, 'ADVANCED', 'C1', TRUE), -- JavaScript sub-courses -(2, 'JavaScript Fundamentals', 'Core JavaScript concepts and syntax', NULL, 1, 'BEGINNER', TRUE), -(2, 'DOM Manipulation Basics', 'Working with the Document Object Model', NULL, 2, 'INTERMEDIATE', TRUE), +(2, 'JavaScript Fundamentals', 'Core JavaScript concepts and syntax', NULL, 1, 'BEGINNER', 'A1', TRUE), +(2, 'DOM Manipulation Basics', 'Working with the Document Object Model', NULL, 2, 'INTERMEDIATE', 'B1', TRUE), -- Java sub-courses -(3, 'Java Core Concepts', 'Essential Java programming principles', NULL, 1, 'BEGINNER', TRUE), -(3, 'Spring Framework Intro', 'Building enterprise applications with Spring', NULL, 2, 'ADVANCED', TRUE), +(3, 'Java Core Concepts', 'Essential Java programming principles', NULL, 1, 'BEGINNER', 'A1', TRUE), +(3, 'Spring Framework Intro', 'Building enterprise applications with Spring', NULL, 2, 'ADVANCED', 'C1', TRUE), -- Data Science sub-courses -(4, 'Data Analysis Fundamentals', 'Learn data manipulation with pandas', NULL, 1, 'BEGINNER', TRUE), -(4, 'Advanced Data Analysis', 'Complex data transformations', NULL, 2, 'ADVANCED', TRUE), +(4, 'Data Analysis Fundamentals', 'Learn data manipulation with pandas', NULL, 1, 'BEGINNER', 'A1', TRUE), +(4, 'Advanced Data Analysis', 'Complex data transformations', NULL, 2, 'ADVANCED', 'C1', TRUE), -- Machine Learning sub-courses -(5, 'ML Basics', 'Introduction to machine learning concepts', NULL, 1, 'BEGINNER', TRUE), -(5, 'ML Algorithms', 'Understanding common ML algorithms', NULL, 2, 'INTERMEDIATE', TRUE), +(5, 'ML Basics', 'Introduction to machine learning concepts', NULL, 1, 'BEGINNER', 'A1', TRUE), +(5, 'ML Algorithms', 'Understanding common ML algorithms', NULL, 2, 'INTERMEDIATE', 'B1', TRUE), -- Full Stack Web Development sub-courses -(6, 'Frontend Fundamentals', 'HTML, CSS, and JavaScript basics', NULL, 1, 'BEGINNER', TRUE), -(6, 'Backend Development', 'Server-side programming', NULL, 2, 'INTERMEDIATE', TRUE), +(6, 'Frontend Fundamentals', 'HTML, CSS, and JavaScript basics', NULL, 1, 'BEGINNER', 'A1', TRUE), +(6, 'Backend Development', 'Server-side programming', NULL, 2, 'INTERMEDIATE', 'B1', TRUE), -- React.js sub-courses -(7, 'React Basics', 'Core React concepts and JSX', NULL, 1, 'BEGINNER', TRUE), -(7, 'React Advanced Patterns', 'Hooks, context, and performance', NULL, 2, 'ADVANCED', TRUE); +(7, 'React Basics', 'Core React concepts and JSX', NULL, 1, 'BEGINNER', 'A1', TRUE), +(7, 'React Advanced Patterns', 'Hooks, context, and performance', NULL, 2, 'ADVANCED', 'C1', TRUE); -- Sub-course Videos INSERT INTO sub_course_videos ( diff --git a/db/data/007_course_management_seed.sql b/db/data/007_course_management_seed.sql index b14fdbf..46ec8df 100644 --- a/db/data/007_course_management_seed.sql +++ b/db/data/007_course_management_seed.sql @@ -30,30 +30,30 @@ 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 +INSERT INTO sub_courses (id, course_id, title, description, thumbnail, display_order, level, sub_level, is_active) VALUES -- Flutter sub-courses (course 8) — IDs 18-21 -(18, 8, 'Dart Language Basics', 'Learn Dart programming language fundamentals', NULL, 1, 'BEGINNER', 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), +(18, 8, 'Dart Language Basics', 'Learn Dart programming language fundamentals', NULL, 1, 'BEGINNER', 'A1', TRUE), +(19, 8, 'Flutter UI Widgets', 'Build beautiful UIs with Flutter widgets', NULL, 2, 'BEGINNER', 'A2', TRUE), +(20, 8, 'State Management', 'Manage app state with Provider and Riverpod', NULL, 3, 'INTERMEDIATE', 'B1', TRUE), +(21, 8, 'Flutter Networking & APIs', 'HTTP requests, REST APIs, and data persistence', NULL, 4, 'ADVANCED', 'C1', TRUE), -- React Native sub-courses (course 9) — IDs 22-24 -(22, 9, 'React Native Setup', 'Environment setup and first app', NULL, 1, 'BEGINNER', 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), +(22, 9, 'React Native Setup', 'Environment setup and first app', NULL, 1, 'BEGINNER', 'A1', TRUE), +(23, 9, 'Navigation & Routing', 'React Navigation and screen management', NULL, 2, 'INTERMEDIATE', 'B1', TRUE), +(24, 9, 'Native Modules', 'Bridge native code with React Native', NULL, 3, 'ADVANCED', 'C1', TRUE), -- Docker & Kubernetes sub-courses (course 10) — IDs 25-27 -(25, 10, 'Docker Fundamentals', 'Containers, images, and Dockerfiles', NULL, 1, 'BEGINNER', TRUE), -(26, 10, 'Docker Compose', 'Multi-container applications', NULL, 2, 'INTERMEDIATE', TRUE), -(27, 10, 'Kubernetes Basics', 'Pods, services, and deployments', NULL, 3, 'ADVANCED', TRUE), +(25, 10, 'Docker Fundamentals', 'Containers, images, and Dockerfiles', NULL, 1, 'BEGINNER', 'A1', TRUE), +(26, 10, 'Docker Compose', 'Multi-container applications', NULL, 2, 'INTERMEDIATE', 'B1', TRUE), +(27, 10, 'Kubernetes Basics', 'Pods, services, and deployments', NULL, 3, 'ADVANCED', 'C1', TRUE), -- CI/CD sub-courses (course 11) — IDs 28-29 -(28, 11, 'Git Workflows', 'Branching strategies and pull requests', NULL, 1, 'BEGINNER', TRUE), -(29, 11, 'GitHub Actions', 'Automate workflows with GitHub Actions', NULL, 2, 'INTERMEDIATE', TRUE), +(28, 11, 'Git Workflows', 'Branching strategies and pull requests', NULL, 1, 'BEGINNER', 'A1', TRUE), +(29, 11, 'GitHub Actions', 'Automate workflows with GitHub Actions', NULL, 2, 'INTERMEDIATE', 'B1', TRUE), -- Cybersecurity sub-courses (course 12) — IDs 30-31 -(30, 12, 'Network Security Basics', 'Firewalls, VPNs, and network protocols', NULL, 1, 'BEGINNER', TRUE), -(31, 12, 'Penetration Testing', 'Tools and techniques for pen testing', NULL, 2, 'ADVANCED', TRUE) +(30, 12, 'Network Security Basics', 'Firewalls, VPNs, and network protocols', NULL, 1, 'BEGINNER', 'A1', TRUE), +(31, 12, 'Penetration Testing', 'Tools and techniques for pen testing', NULL, 2, 'ADVANCED', 'C1', TRUE) ON CONFLICT (id) DO NOTHING; -- ====================================================== @@ -180,6 +180,44 @@ INSERT INTO question_sets (id, title, description, set_type, owner_type, owner_i (10, 'React Native Setup Quiz', 'Test React Native setup knowledge', 'PRACTICE', 'SUB_COURSE', 22, 'beginner', 'PUBLISHED') ON CONFLICT (id) DO NOTHING; +-- Ensure every sub-course has at least one practice set +INSERT INTO question_sets (title, description, set_type, owner_type, owner_id, status) +SELECT + sc.title || ' Practice', + 'Default practice set for ' || sc.title, + 'PRACTICE', + 'SUB_COURSE', + sc.id, + 'DRAFT' +FROM sub_courses sc +WHERE NOT EXISTS ( + SELECT 1 + FROM question_sets qs + WHERE qs.owner_type = 'SUB_COURSE' + AND qs.owner_id = sc.id + AND qs.set_type = 'PRACTICE' + AND qs.status != 'ARCHIVED' +); + +-- Ensure every sub-course has one initial assessment set +INSERT INTO question_sets (title, description, set_type, owner_type, owner_id, status) +SELECT + sc.title || ' Entry Assessment', + 'Initial assessment used before learners start ' || sc.title, + 'INITIAL_ASSESSMENT', + 'SUB_COURSE', + sc.id, + 'DRAFT' +FROM sub_courses sc +WHERE NOT EXISTS ( + SELECT 1 + FROM question_sets qs + WHERE qs.owner_type = 'SUB_COURSE' + AND qs.owner_id = sc.id + AND qs.set_type = 'INITIAL_ASSESSMENT' + AND qs.status != 'ARCHIVED' +); + -- Link questions to question sets INSERT INTO question_set_items (set_id, question_id, display_order) VALUES (5, 21, 1), diff --git a/db/migrations/000024_subcourse_entry_assessment_and_sub_levels.down.sql b/db/migrations/000024_subcourse_entry_assessment_and_sub_levels.down.sql new file mode 100644 index 0000000..5aec9b5 --- /dev/null +++ b/db/migrations/000024_subcourse_entry_assessment_and_sub_levels.down.sql @@ -0,0 +1,12 @@ +DROP TRIGGER IF EXISTS trg_sub_courses_create_entry_assessment ON sub_courses; +DROP FUNCTION IF EXISTS create_sub_course_entry_assessment(); +DROP FUNCTION IF EXISTS clone_default_initial_assessment_items(BIGINT); + +DROP INDEX IF EXISTS idx_question_sets_unique_subcourse_initial_assessment; +DROP INDEX IF EXISTS idx_sub_courses_level_sub_level; + +ALTER TABLE sub_courses +DROP CONSTRAINT IF EXISTS sub_courses_level_sub_level_check; + +ALTER TABLE sub_courses +DROP COLUMN IF EXISTS sub_level; diff --git a/db/migrations/000024_subcourse_entry_assessment_and_sub_levels.up.sql b/db/migrations/000024_subcourse_entry_assessment_and_sub_levels.up.sql new file mode 100644 index 0000000..349ffd5 --- /dev/null +++ b/db/migrations/000024_subcourse_entry_assessment_and_sub_levels.up.sql @@ -0,0 +1,148 @@ +-- Add sub-level support to sub_courses and enforce valid level/sub-level combinations. +ALTER TABLE sub_courses +ADD COLUMN sub_level VARCHAR(2); + +UPDATE sub_courses +SET sub_level = CASE level + WHEN 'BEGINNER' THEN 'A1' + WHEN 'INTERMEDIATE' THEN 'B1' + WHEN 'ADVANCED' THEN 'C1' + ELSE 'A1' +END +WHERE sub_level IS NULL; + +ALTER TABLE sub_courses +ALTER COLUMN sub_level SET NOT NULL; + +ALTER TABLE sub_courses +ADD CONSTRAINT sub_courses_level_sub_level_check CHECK ( + (level = 'BEGINNER' AND sub_level IN ('A1', 'A2', 'A3')) OR + (level = 'INTERMEDIATE' AND sub_level IN ('B1', 'B2', 'B3')) OR + (level = 'ADVANCED' AND sub_level IN ('C1', 'C2', 'C3')) +); + +CREATE INDEX idx_sub_courses_level_sub_level ON sub_courses(level, sub_level); + +-- Ensure each sub-course has an entry-assessment question set. +CREATE UNIQUE INDEX idx_question_sets_unique_subcourse_initial_assessment +ON question_sets(owner_type, owner_id, set_type) +WHERE owner_type = 'SUB_COURSE' AND set_type = 'INITIAL_ASSESSMENT' AND status != 'ARCHIVED'; + +CREATE OR REPLACE FUNCTION clone_default_initial_assessment_items(target_set_id BIGINT) +RETURNS VOID AS $$ +DECLARE + template_set_id BIGINT; +BEGIN + SELECT id + INTO template_set_id + FROM question_sets + WHERE set_type = 'INITIAL_ASSESSMENT' + AND owner_type = 'STANDALONE' + AND status = 'PUBLISHED' + ORDER BY created_at DESC + LIMIT 1; + + IF template_set_id IS NULL THEN + RETURN; + END IF; + + INSERT INTO question_set_items (set_id, question_id, display_order) + SELECT target_set_id, qsi.question_id, qsi.display_order + FROM question_set_items qsi + WHERE qsi.set_id = template_set_id + AND NOT EXISTS ( + SELECT 1 + FROM question_set_items existing + WHERE existing.set_id = target_set_id + AND existing.question_id = qsi.question_id + ); +END; +$$ LANGUAGE plpgsql; + +INSERT INTO question_sets ( + title, + description, + set_type, + owner_type, + owner_id, + shuffle_questions, + status +) +SELECT + sc.title || ' Entry Assessment', + 'Entry assessment used to evaluate learners before joining this sub-course.', + 'INITIAL_ASSESSMENT', + 'SUB_COURSE', + sc.id, + false, + 'DRAFT' +FROM sub_courses sc +WHERE NOT EXISTS ( + SELECT 1 + FROM question_sets qs + WHERE qs.owner_type = 'SUB_COURSE' + AND qs.owner_id = sc.id + AND qs.set_type = 'INITIAL_ASSESSMENT' + AND qs.status != 'ARCHIVED' +); + +DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN + SELECT id + FROM question_sets + WHERE owner_type = 'SUB_COURSE' + AND set_type = 'INITIAL_ASSESSMENT' + AND status != 'ARCHIVED' + LOOP + IF NOT EXISTS (SELECT 1 FROM question_set_items WHERE set_id = r.id) THEN + PERFORM clone_default_initial_assessment_items(r.id); + END IF; + END LOOP; +END; +$$; + +CREATE OR REPLACE FUNCTION create_sub_course_entry_assessment() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO question_sets ( + title, + description, + set_type, + owner_type, + owner_id, + shuffle_questions, + status + ) + VALUES ( + NEW.title || ' Entry Assessment', + 'Entry assessment used to evaluate learners before joining this sub-course.', + 'INITIAL_ASSESSMENT', + 'SUB_COURSE', + NEW.id, + false, + 'DRAFT' + ) + ON CONFLICT DO NOTHING; + + PERFORM clone_default_initial_assessment_items(( + SELECT id + FROM question_sets + WHERE owner_type = 'SUB_COURSE' + AND owner_id = NEW.id + AND set_type = 'INITIAL_ASSESSMENT' + AND status != 'ARCHIVED' + ORDER BY created_at DESC + LIMIT 1 + )); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_sub_courses_create_entry_assessment +AFTER INSERT ON sub_courses +FOR EACH ROW +EXECUTE FUNCTION create_sub_course_entry_assessment(); diff --git a/db/migrations/000025_video_sequence_progress.down.sql b/db/migrations/000025_video_sequence_progress.down.sql new file mode 100644 index 0000000..f179619 --- /dev/null +++ b/db/migrations/000025_video_sequence_progress.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_user_video_progress_sub_course; +DROP INDEX IF EXISTS idx_user_video_progress_unique; + +DROP TABLE IF EXISTS user_sub_course_video_progress; diff --git a/db/migrations/000025_video_sequence_progress.up.sql b/db/migrations/000025_video_sequence_progress.up.sql new file mode 100644 index 0000000..8f876aa --- /dev/null +++ b/db/migrations/000025_video_sequence_progress.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS user_sub_course_video_progress ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + sub_course_id BIGINT NOT NULL REFERENCES sub_courses(id) ON DELETE CASCADE, + video_id BIGINT NOT NULL REFERENCES sub_course_videos(id) ON DELETE CASCADE, + completed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_video_progress_unique +ON user_sub_course_video_progress(user_id, video_id); + +CREATE INDEX IF NOT EXISTS idx_user_video_progress_sub_course +ON user_sub_course_video_progress(user_id, sub_course_id); diff --git a/db/migrations/000026_practice_sequence_progress.down.sql b/db/migrations/000026_practice_sequence_progress.down.sql new file mode 100644 index 0000000..fad8a8a --- /dev/null +++ b/db/migrations/000026_practice_sequence_progress.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_user_practice_progress_sub_course; +DROP INDEX IF EXISTS idx_user_practice_progress_unique; + +DROP TABLE IF EXISTS user_practice_progress; diff --git a/db/migrations/000026_practice_sequence_progress.up.sql b/db/migrations/000026_practice_sequence_progress.up.sql new file mode 100644 index 0000000..c102643 --- /dev/null +++ b/db/migrations/000026_practice_sequence_progress.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS user_practice_progress ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + sub_course_id BIGINT NOT NULL REFERENCES sub_courses(id) ON DELETE CASCADE, + question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE, + completed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_practice_progress_unique +ON user_practice_progress(user_id, question_set_id); + +CREATE INDEX IF NOT EXISTS idx_user_practice_progress_sub_course +ON user_practice_progress(user_id, sub_course_id); diff --git a/db/query/learning_tree.sql b/db/query/learning_tree.sql index 63cd077..075a1b3 100644 --- a/db/query/learning_tree.sql +++ b/db/query/learning_tree.sql @@ -4,7 +4,8 @@ SELECT c.title AS course_title, sc.id AS sub_course_id, sc.title AS sub_course_title, - sc.level AS sub_course_level + sc.level AS sub_course_level, + sc.sub_level AS sub_course_sub_level FROM courses c LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true WHERE c.is_active = true @@ -25,6 +26,7 @@ SELECT sc.thumbnail AS sub_course_thumbnail, sc.display_order AS sub_course_display_order, sc.level AS sub_course_level, + sc.sub_level AS sub_course_sub_level, (SELECT COUNT(*) FROM sub_course_prerequisites WHERE sub_course_id = sc.id) AS prerequisite_count, (SELECT COUNT(*) FROM sub_course_videos WHERE sub_course_id = sc.id AND status = 'PUBLISHED') AS video_count, (SELECT COUNT(*) FROM question_sets WHERE set_type = 'PRACTICE' AND owner_type = 'SUB_COURSE' AND owner_id = sc.id AND status = 'PUBLISHED') AS practice_count @@ -50,7 +52,7 @@ WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1 ORDER BY qs.display_order ASC, qs.created_at; -- name: GetSubCoursePrerequisitesForLearningPath :many -SELECT p.prerequisite_sub_course_id, sc.title, sc.level +SELECT p.prerequisite_sub_course_id, sc.title, sc.level, sc.sub_level FROM sub_course_prerequisites p JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id WHERE p.sub_course_id = $1 diff --git a/db/query/question_sets.sql b/db/query/question_sets.sql index 4020ead..bca258a 100644 --- a/db/query/question_sets.sql +++ b/db/query/question_sets.sql @@ -80,6 +80,16 @@ WHERE set_type = 'INITIAL_ASSESSMENT' ORDER BY created_at DESC LIMIT 1; +-- name: GetSubCourseInitialAssessmentSet :one +SELECT * +FROM question_sets +WHERE set_type = 'INITIAL_ASSESSMENT' + AND owner_type = 'SUB_COURSE' + AND owner_id = $1 + AND status = 'PUBLISHED' +ORDER BY created_at DESC +LIMIT 1; + -- name: AddUserPersonaToQuestionSet :one INSERT INTO question_set_personas ( question_set_id, diff --git a/db/query/sub_courses.sql b/db/query/sub_courses.sql index 979e532..80b3ce3 100644 --- a/db/query/sub_courses.sql +++ b/db/query/sub_courses.sql @@ -6,9 +6,10 @@ INSERT INTO sub_courses ( thumbnail, display_order, level, + sub_level, is_active ) -VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, COALESCE($7, true)) +VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, $7, COALESCE($8, true)) RETURNING *; -- name: GetSubCourseByID :one @@ -26,6 +27,7 @@ SELECT thumbnail, display_order, level, + sub_level, is_active FROM sub_courses WHERE course_id = $1 @@ -40,6 +42,7 @@ SELECT thumbnail, display_order, level, + sub_level, is_active FROM sub_courses WHERE course_id = $1 @@ -55,6 +58,7 @@ SELECT thumbnail, display_order, level, + sub_level, is_active FROM sub_courses WHERE is_active = TRUE @@ -68,8 +72,9 @@ SET thumbnail = COALESCE($3, thumbnail), display_order = COALESCE($4, display_order), level = COALESCE($5, level), - is_active = COALESCE($6, is_active) -WHERE id = $7; + sub_level = COALESCE($6, sub_level), + is_active = COALESCE($7, is_active) +WHERE id = $8; -- name: DeleteSubCourse :one DELETE FROM sub_courses diff --git a/db/query/user_practice_progress.sql b/db/query/user_practice_progress.sql new file mode 100644 index 0000000..83cbfac --- /dev/null +++ b/db/query/user_practice_progress.sql @@ -0,0 +1,51 @@ +-- name: MarkPracticeCompleted :one +INSERT INTO user_practice_progress ( + user_id, + sub_course_id, + question_set_id, + completed_at, + updated_at +) +SELECT + @user_id::BIGINT, + qs.owner_id::BIGINT, + qs.id, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM question_sets qs +WHERE qs.id = @question_set_id::BIGINT + AND qs.set_type = 'PRACTICE' + AND qs.owner_type = 'SUB_COURSE' + AND qs.status = 'PUBLISHED' +ON CONFLICT (user_id, question_set_id) +DO UPDATE SET + completed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +RETURNING *; + +-- name: GetFirstIncompletePreviousPractice :one +SELECT + p.id, + p.title, + p.display_order +FROM question_sets target +JOIN question_sets p + ON p.owner_type = 'SUB_COURSE' + AND p.owner_id = target.owner_id + AND p.set_type = 'PRACTICE' + AND p.status = 'PUBLISHED' + AND ( + p.display_order < target.display_order OR + (p.display_order = target.display_order AND p.id < target.id) + ) +LEFT JOIN user_practice_progress upp + ON upp.question_set_id = p.id + AND upp.user_id = @user_id::BIGINT + AND upp.completed_at IS NOT NULL +WHERE target.id = @question_set_id::BIGINT + AND target.set_type = 'PRACTICE' + AND target.owner_type = 'SUB_COURSE' + AND target.status = 'PUBLISHED' + AND upp.question_set_id IS NULL +ORDER BY p.display_order ASC, p.id ASC +LIMIT 1; diff --git a/db/query/user_sub_course_video_progress.sql b/db/query/user_sub_course_video_progress.sql new file mode 100644 index 0000000..8381e24 --- /dev/null +++ b/db/query/user_sub_course_video_progress.sql @@ -0,0 +1,44 @@ +-- name: MarkVideoCompleted :one +INSERT INTO user_sub_course_video_progress ( + user_id, + sub_course_id, + video_id, + completed_at, + updated_at +) +SELECT + @user_id::BIGINT, + v.sub_course_id, + v.id, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM sub_course_videos v +WHERE v.id = @video_id::BIGINT + AND v.status = 'PUBLISHED' +ON CONFLICT (user_id, video_id) +DO UPDATE SET + completed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +RETURNING *; + +-- name: GetFirstIncompletePreviousVideo :one +SELECT + v.id, + v.title, + v.display_order +FROM sub_course_videos target +JOIN sub_course_videos v + ON v.sub_course_id = target.sub_course_id + AND v.status = 'PUBLISHED' + AND ( + v.display_order < target.display_order OR + (v.display_order = target.display_order AND v.id < target.id) + ) +LEFT JOIN user_sub_course_video_progress p + ON p.video_id = v.id + AND p.user_id = @user_id::BIGINT + AND p.completed_at IS NOT NULL +WHERE target.id = @video_id::BIGINT + AND p.video_id IS NULL +ORDER BY v.display_order ASC, v.id ASC +LIMIT 1; diff --git a/docs/LEARNING_TREE_DRAG_DROP_INTEGRATION.md b/docs/LEARNING_TREE_DRAG_DROP_INTEGRATION.md new file mode 100644 index 0000000..871af95 --- /dev/null +++ b/docs/LEARNING_TREE_DRAG_DROP_INTEGRATION.md @@ -0,0 +1,300 @@ +# Learning Tree + Sequential Access Integration (Admin Panel) + +Complete backend-compatible documentation for the changes implemented today: + +- flexible drag-and-drop reorder for learning tree entities +- sub-course level/sub-level model +- sub-course entry assessment model +- learner sequential unlock rules for videos and practices + +--- + +## 1) What Changed Today + +### 1.1 Reorder + Route/RBAC hardening + +- Reorder endpoints are active and route-safe (static `/reorder` routes registered before dynamic `/:id` routes). +- Reorder permissions are now seeded in RBAC and included in default `ADMIN` permissions: + - `course_categories.reorder` + - `courses.reorder` + - `subcourses.reorder` + - `videos.reorder` + - `practices.reorder` + +### 1.2 Sub-course academic structure + +- Added `sub_courses.sub_level` with enforced mapping: + - `BEGINNER` -> `A1`, `A2`, `A3` + - `INTERMEDIATE` -> `B1`, `B2`, `B3` + - `ADVANCED` -> `C1`, `C2`, `C3` + +### 1.3 Entry assessment per sub-course + +- Each sub-course has its own `INITIAL_ASSESSMENT` question set (`owner_type=SUB_COURSE`). +- Backfill creates missing sets for existing sub-courses. +- Trigger auto-creates entry assessment for newly created sub-courses. +- Existing default standalone initial-assessment questions are cloned into sub-course entry sets when empty. + +### 1.4 Learner sequential unlock (videos + practices) + +- Videos: learner cannot access higher `display_order` videos before completing earlier ones. +- Practices: learner cannot access higher `display_order` practices before completing earlier ones. +- Completion is tracked per learner using dedicated progress tables. + +--- + +## 2) Migrations Added + +Apply in order: + +1. `000024_subcourse_entry_assessment_and_sub_levels` +2. `000025_video_sequence_progress` +3. `000026_practice_sequence_progress` + +### 2.1 `000024_subcourse_entry_assessment_and_sub_levels` + +- adds `sub_courses.sub_level` +- adds level/sub-level check constraint +- adds `idx_sub_courses_level_sub_level` +- enforces one active sub-course entry assessment set (unique index on `question_sets`) +- backfills missing sub-course entry assessment sets +- adds trigger for automatic entry assessment creation + +### 2.2 `000025_video_sequence_progress` + +- creates `user_sub_course_video_progress` +- tracks learner completion by `user_id + video_id` + +### 2.3 `000026_practice_sequence_progress` + +- creates `user_practice_progress` +- tracks learner completion by `user_id + question_set_id` (practice set) + +--- + +## 3) Required Permissions + +### 3.1 Admin panel (read + reorder + content management) + +- `learning_tree.get` +- `course_categories.reorder` +- `courses.reorder` +- `subcourses.reorder` +- `videos.reorder` +- `practices.reorder` +- `question_sets.list_by_owner` (entry-assessment fetch by sub-course) + +### 3.2 Learner runtime (sequential flow) + +- `videos.get` +- `progress.update` (for completion endpoints) +- `question_sets.get` +- `question_set_items.list` + +--- + +## 4) Reorder APIs (Admin) + +All reorder APIs expect: + +```json +{ + "items": [ + { "id": 12, "position": 0 }, + { "id": 7, "position": 1 } + ] +} +``` + +- `id`: entity ID +- `position`: target 0-based `display_order` +- send the full sibling list in the target order + +| Method | Endpoint | Effect | +|---|---|---| +| `PUT` | `/api/v1/course-management/categories/reorder` | updates `course_categories.display_order` | +| `PUT` | `/api/v1/course-management/courses/reorder` | updates `courses.display_order` | +| `PUT` | `/api/v1/course-management/sub-courses/reorder` | updates `sub_courses.display_order` | +| `PUT` | `/api/v1/course-management/videos/reorder` | updates `sub_course_videos.display_order` | +| `PUT` | `/api/v1/course-management/practices/reorder` | updates `question_sets.display_order` (practice sets) | + +Common 400 causes: +- invalid body shape +- empty `items` + +--- + +## 5) Data Fetch APIs for Admin UI + +| Method | Endpoint | Purpose | +|---|---|---| +| `GET` | `/api/v1/course-management/learning-tree` | high-level tree | +| `GET` | `/api/v1/course-management/courses/{courseId}/learning-path` | detailed nested structure | +| `GET` | `/api/v1/question-sets/by-owner?owner_type=SUB_COURSE&owner_id={subCourseId}` | sets under a sub-course | +| `GET` | `/api/v1/question-sets/sub-courses/{subCourseId}/entry-assessment` | entry assessment set for sub-course | + +--- + +## 6) Sub-course Create/Update Contract (Admin) + +### 6.1 Create sub-course + +`POST /api/v1/course-management/sub-courses` + +Required fields now include: + +- `course_id` +- `title` +- `level` (`BEGINNER|INTERMEDIATE|ADVANCED`) +- `sub_level` (`A1..C3` mapped by level) + +Example: + +```json +{ + "course_id": 1, + "title": "Speaking Foundations", + "level": "BEGINNER", + "sub_level": "A2", + "display_order": 1 +} +``` + +### 6.2 Update sub-course + +`PATCH /api/v1/course-management/sub-courses/{id}` + +If `level` or `sub_level` is changed, backend validates the pair. + +--- + +## 7) Entry Assessment Model (Sub-course) + +Each sub-course has one entry assessment set: + +- `set_type = INITIAL_ASSESSMENT` +- `owner_type = SUB_COURSE` +- `owner_id = {subCourseId}` + +### Behavior + +- created automatically for new sub-courses +- backfilled for existing sub-courses +- intended to evaluate learner before joining/starting that sub-course + +--- + +## 8) Learner Sequential Unlock Rules (Runtime, Not Admin Panel) + +## 8.1 Videos + +Access endpoint: + +- `GET /api/v1/course-management/videos/{id}` + +Completion endpoint: + +- `POST /api/v1/progress/videos/{id}/complete` + +Rule: +- learner must complete all previous published videos in same sub-course by `display_order` (and `id` tie-breaker) + +## 8.2 Practices + +Access endpoints: + +- `GET /api/v1/question-sets/{id}` (for practice set) +- `GET /api/v1/question-sets/{setId}/questions` + +Completion endpoint: + +- `POST /api/v1/progress/practices/{id}/complete` + +Rule: +- applies to sets with: + - `set_type = PRACTICE` + - `owner_type = SUB_COURSE` + - `status = PUBLISHED` +- learner must complete all previous published practices in same sub-course by `display_order` (and `id` tie-breaker) + +## 8.3 Role scope + +- Sequence enforcement currently applies to `STUDENT` role. +- Admin/instructor/support are not sequence-blocked. +- Admin panel does not need to call completion endpoints. + +--- + +## 9) Mid-Progress Reorder Behavior + +Current behavior (by design): + +- Access checks always use the **latest `display_order` in DB**. +- If admin reorders while learner is in progress, next unlock path updates immediately. +- Completion records are by entity ID, so already completed items remain completed. + +Implication: +- learner may be asked to complete a newly moved earlier item before continuing. + +--- + +## 10) Recommended Frontend Integration Flow + +### 10.1 Admin panel + +1. Load learning path/tree. +2. Render sortable lists per sibling scope. +3. On drop: + - optimistic reorder in UI + - call matching reorder endpoint with full sibling list +4. Handle `400/401/403/500` and rollback on failure. + +--- + +## 11) Quick API Examples + +### Reorder videos + +```bash +curl -X PUT "http://localhost:8080/api/v1/course-management/videos/reorder" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"items":[{"id":45,"position":0},{"id":42,"position":1},{"id":50,"position":2}]}' +``` + +### Reorder practices + +```bash +curl -X PUT "http://localhost:8080/api/v1/course-management/practices/reorder" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"items":[{"id":88,"position":0},{"id":91,"position":1},{"id":95,"position":2}]}' +``` + +### Get sub-course entry assessment + +```bash +curl -X GET "http://localhost:8080/api/v1/question-sets/sub-courses/10/entry-assessment" \ + -H "Authorization: Bearer " +``` + +### Runtime-only completion endpoints (learner app, optional reference) + +```text +POST /api/v1/progress/videos/{id}/complete +POST /api/v1/progress/practices/{id}/complete +``` + +--- + +## 12) Validation and Testing Checklist + +- [ ] Run migrations 000024, 000025, 000026 +- [ ] Sync RBAC permissions and ensure admin role contains reorder keys +- [ ] Create/update sub-course with valid `level + sub_level` +- [ ] Verify auto-created entry assessment for new sub-course +- [ ] Reorder categories/courses/sub-courses/videos/practices and confirm persistence +- [ ] Learner cannot access video N+1 before completing N +- [ ] Learner cannot access practice N+1 before completing N +- [ ] Reorder during learner progress and confirm latest-order behavior + diff --git a/gen/db/learning_tree.sql.go b/gen/db/learning_tree.sql.go index 9a06f5d..0a19816 100644 --- a/gen/db/learning_tree.sql.go +++ b/gen/db/learning_tree.sql.go @@ -26,6 +26,7 @@ SELECT sc.thumbnail AS sub_course_thumbnail, sc.display_order AS sub_course_display_order, sc.level AS sub_course_level, + sc.sub_level AS sub_course_sub_level, (SELECT COUNT(*) FROM sub_course_prerequisites WHERE sub_course_id = sc.id) AS prerequisite_count, (SELECT COUNT(*) FROM sub_course_videos WHERE sub_course_id = sc.id AND status = 'PUBLISHED') AS video_count, (SELECT COUNT(*) FROM question_sets WHERE set_type = 'PRACTICE' AND owner_type = 'SUB_COURSE' AND owner_id = sc.id AND status = 'PUBLISHED') AS practice_count @@ -50,6 +51,7 @@ type GetCourseLearningPathRow struct { SubCourseThumbnail pgtype.Text `json:"sub_course_thumbnail"` SubCourseDisplayOrder pgtype.Int4 `json:"sub_course_display_order"` SubCourseLevel pgtype.Text `json:"sub_course_level"` + SubCourseSubLevel pgtype.Text `json:"sub_course_sub_level"` PrerequisiteCount int64 `json:"prerequisite_count"` VideoCount int64 `json:"video_count"` PracticeCount int64 `json:"practice_count"` @@ -78,6 +80,7 @@ func (q *Queries) GetCourseLearningPath(ctx context.Context, id int64) ([]GetCou &i.SubCourseThumbnail, &i.SubCourseDisplayOrder, &i.SubCourseLevel, + &i.SubCourseSubLevel, &i.PrerequisiteCount, &i.VideoCount, &i.PracticeCount, @@ -98,7 +101,8 @@ SELECT c.title AS course_title, sc.id AS sub_course_id, sc.title AS sub_course_title, - sc.level AS sub_course_level + sc.level AS sub_course_level, + sc.sub_level AS sub_course_sub_level FROM courses c LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true WHERE c.is_active = true @@ -106,11 +110,12 @@ ORDER BY c.id, sc.display_order, sc.id ` type GetFullLearningTreeRow struct { - CourseID int64 `json:"course_id"` - CourseTitle string `json:"course_title"` - SubCourseID pgtype.Int8 `json:"sub_course_id"` - SubCourseTitle pgtype.Text `json:"sub_course_title"` - SubCourseLevel pgtype.Text `json:"sub_course_level"` + CourseID int64 `json:"course_id"` + CourseTitle string `json:"course_title"` + SubCourseID pgtype.Int8 `json:"sub_course_id"` + SubCourseTitle pgtype.Text `json:"sub_course_title"` + SubCourseLevel pgtype.Text `json:"sub_course_level"` + SubCourseSubLevel pgtype.Text `json:"sub_course_sub_level"` } func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTreeRow, error) { @@ -128,6 +133,7 @@ func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTre &i.SubCourseID, &i.SubCourseTitle, &i.SubCourseLevel, + &i.SubCourseSubLevel, ); err != nil { return nil, err } @@ -185,7 +191,7 @@ func (q *Queries) GetSubCoursePracticesForLearningPath(ctx context.Context, owne } const GetSubCoursePrerequisitesForLearningPath = `-- name: GetSubCoursePrerequisitesForLearningPath :many -SELECT p.prerequisite_sub_course_id, sc.title, sc.level +SELECT p.prerequisite_sub_course_id, sc.title, sc.level, sc.sub_level FROM sub_course_prerequisites p JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id WHERE p.sub_course_id = $1 @@ -196,6 +202,7 @@ type GetSubCoursePrerequisitesForLearningPathRow struct { PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` Title string `json:"title"` Level string `json:"level"` + SubLevel string `json:"sub_level"` } func (q *Queries) GetSubCoursePrerequisitesForLearningPath(ctx context.Context, subCourseID int64) ([]GetSubCoursePrerequisitesForLearningPathRow, error) { @@ -207,7 +214,12 @@ func (q *Queries) GetSubCoursePrerequisitesForLearningPath(ctx context.Context, var items []GetSubCoursePrerequisitesForLearningPathRow for rows.Next() { var i GetSubCoursePrerequisitesForLearningPathRow - if err := rows.Scan(&i.PrerequisiteSubCourseID, &i.Title, &i.Level); err != nil { + if err := rows.Scan( + &i.PrerequisiteSubCourseID, + &i.Title, + &i.Level, + &i.SubLevel, + ); err != nil { return nil, err } items = append(items, i) diff --git a/gen/db/models.go b/gen/db/models.go index cb8097c..146dcf3 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -276,6 +276,7 @@ type SubCourse struct { DisplayOrder int32 `json:"display_order"` Level string `json:"level"` IsActive bool `json:"is_active"` + SubLevel string `json:"sub_level"` } type SubCoursePrerequisite struct { @@ -387,6 +388,16 @@ type User struct { ProfileCompletionPercentage int16 `json:"profile_completion_percentage"` } +type UserPracticeProgress struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + SubCourseID int64 `json:"sub_course_id"` + QuestionSetID int64 `json:"question_set_id"` + CompletedAt pgtype.Timestamp `json:"completed_at"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + type UserSubCourseProgress struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` @@ -399,6 +410,16 @@ type UserSubCourseProgress struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type UserSubCourseVideoProgress struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + SubCourseID int64 `json:"sub_course_id"` + VideoID int64 `json:"video_id"` + CompletedAt pgtype.Timestamp `json:"completed_at"` + CreatedAt pgtype.Timestamp `json:"created_at"` + UpdatedAt pgtype.Timestamp `json:"updated_at"` +} + type UserSubscription struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` diff --git a/gen/db/question_sets.sql.go b/gen/db/question_sets.sql.go index 5c0689b..dac6cbd 100644 --- a/gen/db/question_sets.sql.go +++ b/gen/db/question_sets.sql.go @@ -373,6 +373,41 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets return items, nil } +const GetSubCourseInitialAssessmentSet = `-- name: GetSubCourseInitialAssessmentSet :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, display_order +FROM question_sets +WHERE set_type = 'INITIAL_ASSESSMENT' + AND owner_type = 'SUB_COURSE' + AND owner_id = $1 + AND status = 'PUBLISHED' +ORDER BY created_at DESC +LIMIT 1 +` + +func (q *Queries) GetSubCourseInitialAssessmentSet(ctx context.Context, ownerID pgtype.Int8) (QuestionSet, error) { + row := q.db.QueryRow(ctx, GetSubCourseInitialAssessmentSet, ownerID) + var i QuestionSet + err := row.Scan( + &i.ID, + &i.Title, + &i.Description, + &i.SetType, + &i.OwnerType, + &i.OwnerID, + &i.BannerImage, + &i.Persona, + &i.TimeLimitMinutes, + &i.PassingScore, + &i.ShuffleQuestions, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.SubCourseVideoID, + &i.DisplayOrder, + ) + return i, err +} + const GetUserPersonasByQuestionSetID = `-- name: GetUserPersonasByQuestionSetID :many SELECT u.id, diff --git a/gen/db/sub_courses.sql.go b/gen/db/sub_courses.sql.go index 46b38dd..2d0eb7e 100644 --- a/gen/db/sub_courses.sql.go +++ b/gen/db/sub_courses.sql.go @@ -19,10 +19,11 @@ INSERT INTO sub_courses ( thumbnail, display_order, level, + sub_level, is_active ) -VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, COALESCE($7, true)) -RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active +VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, $7, COALESCE($8, true)) +RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active, sub_level ` type CreateSubCourseParams struct { @@ -32,7 +33,8 @@ type CreateSubCourseParams struct { Thumbnail pgtype.Text `json:"thumbnail"` Column5 interface{} `json:"column_5"` Level string `json:"level"` - Column7 interface{} `json:"column_7"` + SubLevel string `json:"sub_level"` + Column8 interface{} `json:"column_8"` } func (q *Queries) CreateSubCourse(ctx context.Context, arg CreateSubCourseParams) (SubCourse, error) { @@ -43,7 +45,8 @@ func (q *Queries) CreateSubCourse(ctx context.Context, arg CreateSubCourseParams arg.Thumbnail, arg.Column5, arg.Level, - arg.Column7, + arg.SubLevel, + arg.Column8, ) var i SubCourse err := row.Scan( @@ -55,6 +58,7 @@ func (q *Queries) CreateSubCourse(ctx context.Context, arg CreateSubCourseParams &i.DisplayOrder, &i.Level, &i.IsActive, + &i.SubLevel, ) return i, err } @@ -73,7 +77,7 @@ func (q *Queries) DeactivateSubCourse(ctx context.Context, id int64) error { const DeleteSubCourse = `-- name: DeleteSubCourse :one DELETE FROM sub_courses WHERE id = $1 -RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active +RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active, sub_level ` func (q *Queries) DeleteSubCourse(ctx context.Context, id int64) (SubCourse, error) { @@ -88,12 +92,13 @@ func (q *Queries) DeleteSubCourse(ctx context.Context, id int64) (SubCourse, err &i.DisplayOrder, &i.Level, &i.IsActive, + &i.SubLevel, ) return i, err } const GetSubCourseByID = `-- name: GetSubCourseByID :one -SELECT id, course_id, title, description, thumbnail, display_order, level, is_active +SELECT id, course_id, title, description, thumbnail, display_order, level, is_active, sub_level FROM sub_courses WHERE id = $1 ` @@ -110,6 +115,7 @@ func (q *Queries) GetSubCourseByID(ctx context.Context, id int64) (SubCourse, er &i.DisplayOrder, &i.Level, &i.IsActive, + &i.SubLevel, ) return i, err } @@ -124,6 +130,7 @@ SELECT thumbnail, display_order, level, + sub_level, is_active FROM sub_courses WHERE course_id = $1 @@ -139,6 +146,7 @@ type GetSubCoursesByCourseRow struct { Thumbnail pgtype.Text `json:"thumbnail"` DisplayOrder int32 `json:"display_order"` Level string `json:"level"` + SubLevel string `json:"sub_level"` IsActive bool `json:"is_active"` } @@ -160,6 +168,7 @@ func (q *Queries) GetSubCoursesByCourse(ctx context.Context, courseID int64) ([] &i.Thumbnail, &i.DisplayOrder, &i.Level, + &i.SubLevel, &i.IsActive, ); err != nil { return nil, err @@ -181,21 +190,34 @@ SELECT thumbnail, display_order, level, + sub_level, is_active FROM sub_courses WHERE is_active = TRUE ORDER BY display_order ASC ` -func (q *Queries) ListActiveSubCourses(ctx context.Context) ([]SubCourse, error) { +type ListActiveSubCoursesRow struct { + ID int64 `json:"id"` + CourseID int64 `json:"course_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + DisplayOrder int32 `json:"display_order"` + Level string `json:"level"` + SubLevel string `json:"sub_level"` + IsActive bool `json:"is_active"` +} + +func (q *Queries) ListActiveSubCourses(ctx context.Context) ([]ListActiveSubCoursesRow, error) { rows, err := q.db.Query(ctx, ListActiveSubCourses) if err != nil { return nil, err } defer rows.Close() - var items []SubCourse + var items []ListActiveSubCoursesRow for rows.Next() { - var i SubCourse + var i ListActiveSubCoursesRow if err := rows.Scan( &i.ID, &i.CourseID, @@ -204,6 +226,7 @@ func (q *Queries) ListActiveSubCourses(ctx context.Context) ([]SubCourse, error) &i.Thumbnail, &i.DisplayOrder, &i.Level, + &i.SubLevel, &i.IsActive, ); err != nil { return nil, err @@ -225,6 +248,7 @@ SELECT thumbnail, display_order, level, + sub_level, is_active FROM sub_courses WHERE course_id = $1 @@ -232,15 +256,27 @@ WHERE course_id = $1 ORDER BY display_order ASC, id ASC ` -func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([]SubCourse, error) { +type ListSubCoursesByCourseRow struct { + ID int64 `json:"id"` + CourseID int64 `json:"course_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + DisplayOrder int32 `json:"display_order"` + Level string `json:"level"` + SubLevel string `json:"sub_level"` + IsActive bool `json:"is_active"` +} + +func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([]ListSubCoursesByCourseRow, error) { rows, err := q.db.Query(ctx, ListSubCoursesByCourse, courseID) if err != nil { return nil, err } defer rows.Close() - var items []SubCourse + var items []ListSubCoursesByCourseRow for rows.Next() { - var i SubCourse + var i ListSubCoursesByCourseRow if err := rows.Scan( &i.ID, &i.CourseID, @@ -249,6 +285,7 @@ func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([ &i.Thumbnail, &i.DisplayOrder, &i.Level, + &i.SubLevel, &i.IsActive, ); err != nil { return nil, err @@ -288,8 +325,9 @@ SET thumbnail = COALESCE($3, thumbnail), display_order = COALESCE($4, display_order), level = COALESCE($5, level), - is_active = COALESCE($6, is_active) -WHERE id = $7 + sub_level = COALESCE($6, sub_level), + is_active = COALESCE($7, is_active) +WHERE id = $8 ` type UpdateSubCourseParams struct { @@ -298,6 +336,7 @@ type UpdateSubCourseParams struct { Thumbnail pgtype.Text `json:"thumbnail"` DisplayOrder int32 `json:"display_order"` Level string `json:"level"` + SubLevel string `json:"sub_level"` IsActive bool `json:"is_active"` ID int64 `json:"id"` } @@ -309,6 +348,7 @@ func (q *Queries) UpdateSubCourse(ctx context.Context, arg UpdateSubCourseParams arg.Thumbnail, arg.DisplayOrder, arg.Level, + arg.SubLevel, arg.IsActive, arg.ID, ) diff --git a/gen/db/user_practice_progress.sql.go b/gen/db/user_practice_progress.sql.go new file mode 100644 index 0000000..f077703 --- /dev/null +++ b/gen/db/user_practice_progress.sql.go @@ -0,0 +1,102 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: user_practice_progress.sql + +package dbgen + +import ( + "context" +) + +const GetFirstIncompletePreviousPractice = `-- name: GetFirstIncompletePreviousPractice :one +SELECT + p.id, + p.title, + p.display_order +FROM question_sets target +JOIN question_sets p + ON p.owner_type = 'SUB_COURSE' + AND p.owner_id = target.owner_id + AND p.set_type = 'PRACTICE' + AND p.status = 'PUBLISHED' + AND ( + p.display_order < target.display_order OR + (p.display_order = target.display_order AND p.id < target.id) + ) +LEFT JOIN user_practice_progress upp + ON upp.question_set_id = p.id + AND upp.user_id = $1::BIGINT + AND upp.completed_at IS NOT NULL +WHERE target.id = $2::BIGINT + AND target.set_type = 'PRACTICE' + AND target.owner_type = 'SUB_COURSE' + AND target.status = 'PUBLISHED' + AND upp.question_set_id IS NULL +ORDER BY p.display_order ASC, p.id ASC +LIMIT 1 +` + +type GetFirstIncompletePreviousPracticeParams struct { + UserID int64 `json:"user_id"` + QuestionSetID int64 `json:"question_set_id"` +} + +type GetFirstIncompletePreviousPracticeRow struct { + ID int64 `json:"id"` + Title string `json:"title"` + DisplayOrder int32 `json:"display_order"` +} + +func (q *Queries) GetFirstIncompletePreviousPractice(ctx context.Context, arg GetFirstIncompletePreviousPracticeParams) (GetFirstIncompletePreviousPracticeRow, error) { + row := q.db.QueryRow(ctx, GetFirstIncompletePreviousPractice, arg.UserID, arg.QuestionSetID) + var i GetFirstIncompletePreviousPracticeRow + err := row.Scan(&i.ID, &i.Title, &i.DisplayOrder) + return i, err +} + +const MarkPracticeCompleted = `-- name: MarkPracticeCompleted :one +INSERT INTO user_practice_progress ( + user_id, + sub_course_id, + question_set_id, + completed_at, + updated_at +) +SELECT + $1::BIGINT, + qs.owner_id::BIGINT, + qs.id, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM question_sets qs +WHERE qs.id = $2::BIGINT + AND qs.set_type = 'PRACTICE' + AND qs.owner_type = 'SUB_COURSE' + AND qs.status = 'PUBLISHED' +ON CONFLICT (user_id, question_set_id) +DO UPDATE SET + completed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +RETURNING id, user_id, sub_course_id, question_set_id, completed_at, created_at, updated_at +` + +type MarkPracticeCompletedParams struct { + UserID int64 `json:"user_id"` + QuestionSetID int64 `json:"question_set_id"` +} + +func (q *Queries) MarkPracticeCompleted(ctx context.Context, arg MarkPracticeCompletedParams) (UserPracticeProgress, error) { + row := q.db.QueryRow(ctx, MarkPracticeCompleted, arg.UserID, arg.QuestionSetID) + var i UserPracticeProgress + err := row.Scan( + &i.ID, + &i.UserID, + &i.SubCourseID, + &i.QuestionSetID, + &i.CompletedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/gen/db/user_sub_course_video_progress.sql.go b/gen/db/user_sub_course_video_progress.sql.go new file mode 100644 index 0000000..c1815c9 --- /dev/null +++ b/gen/db/user_sub_course_video_progress.sql.go @@ -0,0 +1,95 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: user_sub_course_video_progress.sql + +package dbgen + +import ( + "context" +) + +const GetFirstIncompletePreviousVideo = `-- name: GetFirstIncompletePreviousVideo :one +SELECT + v.id, + v.title, + v.display_order +FROM sub_course_videos target +JOIN sub_course_videos v + ON v.sub_course_id = target.sub_course_id + AND v.status = 'PUBLISHED' + AND ( + v.display_order < target.display_order OR + (v.display_order = target.display_order AND v.id < target.id) + ) +LEFT JOIN user_sub_course_video_progress p + ON p.video_id = v.id + AND p.user_id = $1::BIGINT + AND p.completed_at IS NOT NULL +WHERE target.id = $2::BIGINT + AND p.video_id IS NULL +ORDER BY v.display_order ASC, v.id ASC +LIMIT 1 +` + +type GetFirstIncompletePreviousVideoParams struct { + UserID int64 `json:"user_id"` + VideoID int64 `json:"video_id"` +} + +type GetFirstIncompletePreviousVideoRow struct { + ID int64 `json:"id"` + Title string `json:"title"` + DisplayOrder int32 `json:"display_order"` +} + +func (q *Queries) GetFirstIncompletePreviousVideo(ctx context.Context, arg GetFirstIncompletePreviousVideoParams) (GetFirstIncompletePreviousVideoRow, error) { + row := q.db.QueryRow(ctx, GetFirstIncompletePreviousVideo, arg.UserID, arg.VideoID) + var i GetFirstIncompletePreviousVideoRow + err := row.Scan(&i.ID, &i.Title, &i.DisplayOrder) + return i, err +} + +const MarkVideoCompleted = `-- name: MarkVideoCompleted :one +INSERT INTO user_sub_course_video_progress ( + user_id, + sub_course_id, + video_id, + completed_at, + updated_at +) +SELECT + $1::BIGINT, + v.sub_course_id, + v.id, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM sub_course_videos v +WHERE v.id = $2::BIGINT + AND v.status = 'PUBLISHED' +ON CONFLICT (user_id, video_id) +DO UPDATE SET + completed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +RETURNING id, user_id, sub_course_id, video_id, completed_at, created_at, updated_at +` + +type MarkVideoCompletedParams struct { + UserID int64 `json:"user_id"` + VideoID int64 `json:"video_id"` +} + +func (q *Queries) MarkVideoCompleted(ctx context.Context, arg MarkVideoCompletedParams) (UserSubCourseVideoProgress, error) { + row := q.db.QueryRow(ctx, MarkVideoCompleted, arg.UserID, arg.VideoID) + var i UserSubCourseVideoProgress + err := row.Scan( + &i.ID, + &i.UserID, + &i.SubCourseID, + &i.VideoID, + &i.CompletedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/domain/course_management.go b/internal/domain/course_management.go index 6f6f9a2..bec9ce1 100644 --- a/internal/domain/course_management.go +++ b/internal/domain/course_management.go @@ -10,6 +10,20 @@ const ( SubCourseLevelAdvanced SubCourseLevel = "ADVANCED" ) +type SubCourseSubLevel string + +const ( + SubCourseSubLevelA1 SubCourseSubLevel = "A1" + SubCourseSubLevelA2 SubCourseSubLevel = "A2" + SubCourseSubLevelA3 SubCourseSubLevel = "A3" + SubCourseSubLevelB1 SubCourseSubLevel = "B1" + SubCourseSubLevelB2 SubCourseSubLevel = "B2" + SubCourseSubLevelB3 SubCourseSubLevel = "B3" + SubCourseSubLevelC1 SubCourseSubLevel = "C1" + SubCourseSubLevelC2 SubCourseSubLevel = "C2" + SubCourseSubLevelC3 SubCourseSubLevel = "C3" +) + type ContentStatus string const ( @@ -20,9 +34,10 @@ const ( ) type TreeSubCourse struct { - ID int64 - Title string - Level string + ID int64 + Title string + Level string + SubLevel string } type TreeCourse struct { @@ -56,6 +71,7 @@ type SubCourse struct { Thumbnail *string DisplayOrder int32 Level string + SubLevel string IsActive bool } @@ -88,6 +104,12 @@ const ( VideoHostProviderVimeo VideoHostProvider = "VIMEO" ) +type VideoAccessBlock struct { + VideoID int64 + Title string + DisplayOrder int32 +} + // Learning Path types — full nested structure for a course type LearningPathVideo struct { ID int64 `json:"id"` @@ -115,6 +137,7 @@ type LearningPathPrerequisite struct { SubCourseID int64 `json:"sub_course_id"` Title string `json:"title"` Level string `json:"level"` + SubLevel string `json:"sub_level"` } type LearningPathSubCourse struct { @@ -124,6 +147,7 @@ type LearningPathSubCourse struct { Thumbnail *string `json:"thumbnail,omitempty"` DisplayOrder int32 `json:"display_order"` Level string `json:"level"` + SubLevel string `json:"sub_level"` PrerequisiteCount int64 `json:"prerequisite_count"` VideoCount int64 `json:"video_count"` PracticeCount int64 `json:"practice_count"` diff --git a/internal/domain/questions.go b/internal/domain/questions.go index 958db71..5cb4dce 100644 --- a/internal/domain/questions.go +++ b/internal/domain/questions.go @@ -29,6 +29,12 @@ const ( QuestionSetTypeSurvey QuestionSetType = "SURVEY" ) +type PracticeAccessBlock struct { + QuestionSetID int64 + Title string + DisplayOrder int32 +} + type MatchType string const ( diff --git a/internal/ports/course_management.go b/internal/ports/course_management.go index f0259d5..ab68d81 100644 --- a/internal/ports/course_management.go +++ b/internal/ports/course_management.go @@ -73,6 +73,7 @@ type CourseStore interface { thumbnail *string, displayOrder *int32, level string, + subLevel string, ) (domain.SubCourse, error) GetSubCourseByID( ctx context.Context, @@ -97,6 +98,7 @@ type CourseStore interface { thumbnail *string, displayOrder *int32, level *string, + subLevel *string, isActive *bool, ) error DeactivateSubCourse( @@ -140,6 +142,16 @@ type CourseStore interface { ctx context.Context, subCourseID int64, ) ([]domain.SubCourseVideo, error) + GetFirstIncompletePreviousVideo( + ctx context.Context, + userID int64, + videoID int64, + ) (*domain.VideoAccessBlock, error) + MarkVideoCompleted( + ctx context.Context, + userID int64, + videoID int64, + ) error PublishSubCourseVideo( ctx context.Context, videoID int64, diff --git a/internal/ports/questions.go b/internal/ports/questions.go index c1b40c4..753868f 100644 --- a/internal/ports/questions.go +++ b/internal/ports/questions.go @@ -37,6 +37,9 @@ type QuestionStore interface { GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error) GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet, error) + GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error) + GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) + MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error ArchiveQuestionSet(ctx context.Context, id int64) error DeleteQuestionSet(ctx context.Context, id int64) error diff --git a/internal/repository/learning_tree.go b/internal/repository/learning_tree.go index d5e3edc..8b62cac 100644 --- a/internal/repository/learning_tree.go +++ b/internal/repository/learning_tree.go @@ -29,9 +29,10 @@ func (s *Store) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, e if row.SubCourseID.Valid { subCourse := domain.TreeSubCourse{ - ID: row.SubCourseID.Int64, - Title: row.SubCourseTitle.String, - Level: row.SubCourseLevel.String, + ID: row.SubCourseID.Int64, + Title: row.SubCourseTitle.String, + Level: row.SubCourseLevel.String, + SubLevel: row.SubCourseSubLevel.String, } course.SubCourses = append(course.SubCourses, subCourse) } @@ -85,6 +86,7 @@ func (s *Store) GetCourseLearningPath(ctx context.Context, courseID int64) (doma Thumbnail: ptrString(row.SubCourseThumbnail), DisplayOrder: row.SubCourseDisplayOrder.Int32, Level: row.SubCourseLevel.String, + SubLevel: row.SubCourseSubLevel.String, PrerequisiteCount: row.PrerequisiteCount, VideoCount: row.VideoCount, PracticeCount: row.PracticeCount, @@ -109,6 +111,7 @@ func (s *Store) getSubCoursePrerequisitesForPath(ctx context.Context, subCourseI SubCourseID: row.PrerequisiteSubCourseID, Title: row.Title, Level: row.Level, + SubLevel: row.SubLevel, } } return result, nil diff --git a/internal/repository/questions.go b/internal/repository/questions.go index ac64e0d..82f49e2 100644 --- a/internal/repository/questions.go +++ b/internal/repository/questions.go @@ -2,11 +2,13 @@ package repository import ( "context" + "errors" "time" dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/domain" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -632,6 +634,40 @@ func (s *Store) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet return questionSetToDomain(qs), nil } +func (s *Store) GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error) { + qs, err := s.queries.GetSubCourseInitialAssessmentSet(ctx, pgtype.Int8{Int64: subCourseID, Valid: true}) + if err != nil { + return domain.QuestionSet{}, err + } + return questionSetToDomain(qs), nil +} + +func (s *Store) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) { + row, err := s.queries.GetFirstIncompletePreviousPractice(ctx, dbgen.GetFirstIncompletePreviousPracticeParams{ + UserID: userID, + QuestionSetID: questionSetID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &domain.PracticeAccessBlock{ + QuestionSetID: row.ID, + Title: row.Title, + DisplayOrder: row.DisplayOrder, + }, nil +} + +func (s *Store) MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error { + _, err := s.queries.MarkPracticeCompleted(ctx, dbgen.MarkPracticeCompletedParams{ + UserID: userID, + QuestionSetID: questionSetID, + }) + return err +} + func (s *Store) UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error { var shuffleQuestions bool if input.ShuffleQuestions != nil { diff --git a/internal/repository/sub_course_videos.go b/internal/repository/sub_course_videos.go index 67b3c20..66fcf71 100644 --- a/internal/repository/sub_course_videos.go +++ b/internal/repository/sub_course_videos.go @@ -4,7 +4,9 @@ import ( dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/domain" "context" + "errors" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -167,6 +169,41 @@ func (s *Store) GetPublishedVideosBySubCourse( return videos, nil } +func (s *Store) GetFirstIncompletePreviousVideo( + ctx context.Context, + userID int64, + videoID int64, +) (*domain.VideoAccessBlock, error) { + row, err := s.queries.GetFirstIncompletePreviousVideo(ctx, dbgen.GetFirstIncompletePreviousVideoParams{ + UserID: userID, + VideoID: videoID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + return &domain.VideoAccessBlock{ + VideoID: row.ID, + Title: row.Title, + DisplayOrder: row.DisplayOrder, + }, nil +} + +func (s *Store) MarkVideoCompleted( + ctx context.Context, + userID int64, + videoID int64, +) error { + _, err := s.queries.MarkVideoCompleted(ctx, dbgen.MarkVideoCompletedParams{ + UserID: userID, + VideoID: videoID, + }) + return err +} + func (s *Store) PublishSubCourseVideo( ctx context.Context, videoID int64, diff --git a/internal/repository/sub_courses.go b/internal/repository/sub_courses.go index 05634ff..fd031d6 100644 --- a/internal/repository/sub_courses.go +++ b/internal/repository/sub_courses.go @@ -16,6 +16,7 @@ func (s *Store) CreateSubCourse( thumbnail *string, displayOrder *int32, level string, + subLevel string, ) (domain.SubCourse, error) { var descText, thumbText pgtype.Text if description != nil { @@ -37,7 +38,8 @@ func (s *Store) CreateSubCourse( Thumbnail: thumbText, Column5: dispOrder, Level: level, - Column7: pgtype.Bool{Bool: true, Valid: true}, + SubLevel: subLevel, + Column8: pgtype.Bool{Bool: true, Valid: true}, }) if err != nil { return domain.SubCourse{}, err @@ -51,6 +53,7 @@ func (s *Store) CreateSubCourse( Thumbnail: ptrString(row.Thumbnail), DisplayOrder: row.DisplayOrder, Level: row.Level, + SubLevel: row.SubLevel, IsActive: row.IsActive, }, nil } @@ -72,6 +75,7 @@ func (s *Store) GetSubCourseByID( Thumbnail: ptrString(row.Thumbnail), DisplayOrder: row.DisplayOrder, Level: row.Level, + SubLevel: row.SubLevel, IsActive: row.IsActive, }, nil } @@ -103,6 +107,7 @@ func (s *Store) GetSubCoursesByCourse( Thumbnail: ptrString(row.Thumbnail), DisplayOrder: row.DisplayOrder, Level: row.Level, + SubLevel: row.SubLevel, IsActive: row.IsActive, }) } @@ -129,6 +134,7 @@ func (s *Store) ListSubCoursesByCourse( Thumbnail: ptrString(row.Thumbnail), DisplayOrder: row.DisplayOrder, Level: row.Level, + SubLevel: row.SubLevel, IsActive: row.IsActive, }) } @@ -154,6 +160,7 @@ func (s *Store) ListActiveSubCourses( Thumbnail: ptrString(row.Thumbnail), DisplayOrder: row.DisplayOrder, Level: row.Level, + SubLevel: row.SubLevel, IsActive: row.IsActive, }) } @@ -169,9 +176,10 @@ func (s *Store) UpdateSubCourse( thumbnail *string, displayOrder *int32, level *string, + subLevel *string, isActive *bool, ) error { - var titleVal, descVal, thumbVal, levelVal string + var titleVal, descVal, thumbVal, levelVal, subLevelVal string var dispOrderVal int32 var isActiveVal bool @@ -190,6 +198,9 @@ func (s *Store) UpdateSubCourse( if level != nil { levelVal = *level } + if subLevel != nil { + subLevelVal = *subLevel + } if isActive != nil { isActiveVal = *isActive } @@ -200,6 +211,7 @@ func (s *Store) UpdateSubCourse( Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil}, DisplayOrder: dispOrderVal, Level: levelVal, + SubLevel: subLevelVal, IsActive: isActiveVal, ID: id, }) @@ -229,6 +241,7 @@ func (s *Store) DeleteSubCourse( Thumbnail: ptrString(row.Thumbnail), DisplayOrder: row.DisplayOrder, Level: row.Level, + SubLevel: row.SubLevel, IsActive: row.IsActive, }, nil } diff --git a/internal/services/course_management/sub_course_videos.go b/internal/services/course_management/sub_course_videos.go index 4cbdf7e..1a25b7b 100644 --- a/internal/services/course_management/sub_course_videos.go +++ b/internal/services/course_management/sub_course_videos.go @@ -245,6 +245,22 @@ func (s *Service) GetPublishedVideosBySubCourse( return s.courseStore.GetPublishedVideosBySubCourse(ctx, subCourseID) } +func (s *Service) GetFirstIncompletePreviousVideo( + ctx context.Context, + userID int64, + videoID int64, +) (*domain.VideoAccessBlock, error) { + return s.courseStore.GetFirstIncompletePreviousVideo(ctx, userID, videoID) +} + +func (s *Service) MarkVideoCompleted( + ctx context.Context, + userID int64, + videoID int64, +) error { + return s.courseStore.MarkVideoCompleted(ctx, userID, videoID) +} + func (s *Service) PublishSubCourseVideo( ctx context.Context, videoID int64, diff --git a/internal/services/course_management/sub_courses.go b/internal/services/course_management/sub_courses.go index 106172d..9bf8545 100644 --- a/internal/services/course_management/sub_courses.go +++ b/internal/services/course_management/sub_courses.go @@ -13,8 +13,9 @@ func (s *Service) CreateSubCourse( thumbnail *string, displayOrder *int32, level string, + subLevel string, ) (domain.SubCourse, error) { - return s.courseStore.CreateSubCourse(ctx, courseID, title, description, thumbnail, displayOrder, level) + return s.courseStore.CreateSubCourse(ctx, courseID, title, description, thumbnail, displayOrder, level, subLevel) } func (s *Service) GetSubCourseByID( @@ -52,9 +53,10 @@ func (s *Service) UpdateSubCourse( thumbnail *string, displayOrder *int32, level *string, + subLevel *string, isActive *bool, ) error { - return s.courseStore.UpdateSubCourse(ctx, id, title, description, thumbnail, displayOrder, level, isActive) + return s.courseStore.UpdateSubCourse(ctx, id, title, description, thumbnail, displayOrder, level, subLevel, isActive) } func (s *Service) DeactivateSubCourse( diff --git a/internal/services/questions/service.go b/internal/services/questions/service.go index eec79de..a544dfc 100644 --- a/internal/services/questions/service.go +++ b/internal/services/questions/service.go @@ -120,6 +120,18 @@ func (s *Service) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionS return s.questionStore.GetInitialAssessmentSet(ctx) } +func (s *Service) GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error) { + return s.questionStore.GetSubCourseInitialAssessmentSet(ctx, subCourseID) +} + +func (s *Service) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) { + return s.questionStore.GetFirstIncompletePreviousPractice(ctx, userID, questionSetID) +} + +func (s *Service) MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error { + return s.questionStore.MarkPracticeCompleted(ctx, userID, questionSetID) +} + func (s *Service) UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error { return s.questionStore.UpdateQuestionSet(ctx, id, input) } diff --git a/internal/web_server/handlers/course_management.go b/internal/web_server/handlers/course_management.go index 2e3db6e..716b6b4 100644 --- a/internal/web_server/handlers/course_management.go +++ b/internal/web_server/handlers/course_management.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "github.com/gofiber/fiber/v2" "github.com/google/uuid" @@ -579,6 +580,7 @@ type createSubCourseReq struct { Thumbnail *string `json:"thumbnail"` DisplayOrder *int32 `json:"display_order"` Level string `json:"level" validate:"required"` // BEGINNER, INTERMEDIATE, ADVANCED + SubLevel string `json:"sub_level" validate:"required"` // A1..C3 depending on level } type subCourseRes struct { @@ -589,9 +591,29 @@ type subCourseRes struct { Thumbnail *string `json:"thumbnail"` DisplayOrder int32 `json:"display_order"` Level string `json:"level"` + SubLevel string `json:"sub_level"` IsActive bool `json:"is_active"` } +func isValidSubLevelForLevel(level, subLevel string) bool { + switch strings.ToUpper(level) { + case string(domain.SubCourseLevelBeginner): + return subLevel == string(domain.SubCourseSubLevelA1) || + subLevel == string(domain.SubCourseSubLevelA2) || + subLevel == string(domain.SubCourseSubLevelA3) + case string(domain.SubCourseLevelIntermediate): + return subLevel == string(domain.SubCourseSubLevelB1) || + subLevel == string(domain.SubCourseSubLevelB2) || + subLevel == string(domain.SubCourseSubLevelB3) + case string(domain.SubCourseLevelAdvanced): + return subLevel == string(domain.SubCourseSubLevelC1) || + subLevel == string(domain.SubCourseSubLevelC2) || + subLevel == string(domain.SubCourseSubLevelC3) + default: + return false + } +} + // CreateSubCourse godoc // @Summary Create a new sub-course // @Description Creates a new sub-course under a specific course @@ -612,7 +634,14 @@ func (h *Handler) CreateSubCourse(c *fiber.Ctx) error { }) } - subCourse, err := h.courseMgmtSvc.CreateSubCourse(c.Context(), req.CourseID, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.Level) + if !isValidSubLevelForLevel(req.Level, req.SubLevel) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub_level for the selected level", + Error: "BEGINNER requires A1/A2/A3, INTERMEDIATE requires B1/B2/B3, ADVANCED requires C1/C2/C3", + }) + } + + subCourse, err := h.courseMgmtSvc.CreateSubCourse(c.Context(), req.CourseID, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.Level, req.SubLevel) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to create sub-course", @@ -624,7 +653,7 @@ func (h *Handler) CreateSubCourse(c *fiber.Ctx) error { actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"title": subCourse.Title, "course_id": subCourse.CourseID, "level": subCourse.Level}) + meta, _ := json.Marshal(map[string]interface{}{"title": subCourse.Title, "course_id": subCourse.CourseID, "level": subCourse.Level, "sub_level": subCourse.SubLevel}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseCreated, domain.ResourceSubCourse, &subCourse.ID, "Created sub-course: "+subCourse.Title, meta, &ip, &ua) go func() { @@ -647,6 +676,7 @@ func (h *Handler) CreateSubCourse(c *fiber.Ctx) error { Thumbnail: subCourse.Thumbnail, DisplayOrder: subCourse.DisplayOrder, Level: subCourse.Level, + SubLevel: subCourse.SubLevel, IsActive: subCourse.IsActive, }, }) @@ -691,6 +721,7 @@ func (h *Handler) GetSubCourseByID(c *fiber.Ctx) error { Thumbnail: subCourse.Thumbnail, DisplayOrder: subCourse.DisplayOrder, Level: subCourse.Level, + SubLevel: subCourse.SubLevel, IsActive: subCourse.IsActive, }, }) @@ -739,6 +770,7 @@ func (h *Handler) GetSubCoursesByCourse(c *fiber.Ctx) error { Thumbnail: sc.Thumbnail, DisplayOrder: sc.DisplayOrder, Level: sc.Level, + SubLevel: sc.SubLevel, IsActive: sc.IsActive, }) } @@ -790,6 +822,7 @@ func (h *Handler) ListSubCoursesByCourse(c *fiber.Ctx) error { Thumbnail: sc.Thumbnail, DisplayOrder: sc.DisplayOrder, Level: sc.Level, + SubLevel: sc.SubLevel, IsActive: sc.IsActive, }) } @@ -827,6 +860,7 @@ func (h *Handler) ListActiveSubCourses(c *fiber.Ctx) error { Thumbnail: sc.Thumbnail, DisplayOrder: sc.DisplayOrder, Level: sc.Level, + SubLevel: sc.SubLevel, IsActive: sc.IsActive, }) } @@ -843,6 +877,7 @@ type updateSubCourseReq struct { Thumbnail *string `json:"thumbnail"` DisplayOrder *int32 `json:"display_order"` Level *string `json:"level"` + SubLevel *string `json:"sub_level"` IsActive *bool `json:"is_active"` } @@ -876,7 +911,33 @@ func (h *Handler) UpdateSubCourse(c *fiber.Ctx) error { }) } - err = h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.Level, req.IsActive) + if req.Level != nil || req.SubLevel != nil { + existing, getErr := h.courseMgmtSvc.GetSubCourseByID(c.Context(), id) + if getErr != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Sub-course not found", + Error: getErr.Error(), + }) + } + + level := existing.Level + subLevel := existing.SubLevel + if req.Level != nil { + level = *req.Level + } + if req.SubLevel != nil { + subLevel = *req.SubLevel + } + + if !isValidSubLevelForLevel(level, subLevel) { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub_level for the selected level", + Error: "BEGINNER requires A1/A2/A3, INTERMEDIATE requires B1/B2/B3, ADVANCED requires C1/C2/C3", + }) + } + } + + err = h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.Level, req.SubLevel, req.IsActive) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update sub-course", @@ -888,7 +949,7 @@ func (h *Handler) UpdateSubCourse(c *fiber.Ctx) error { actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"id": id, "title": req.Title, "description": req.Description, "level": req.Level, "is_active": req.IsActive}) + meta, _ := json.Marshal(map[string]interface{}{"id": id, "title": req.Title, "description": req.Description, "level": req.Level, "sub_level": req.SubLevel, "is_active": req.IsActive}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseUpdated, domain.ResourceSubCourse, &id, fmt.Sprintf("Updated sub-course ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ @@ -1136,6 +1197,31 @@ func (h *Handler) GetSubCourseVideoByID(c *fiber.Ctx) error { }) } + role := c.Locals("role").(domain.Role) + if role == domain.RoleStudent { + userID := c.Locals("user_id").(int64) + + if video.Status != string(domain.ContentStatusPublished) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Video not found", + }) + } + + blockedBy, err := h.courseMgmtSvc.GetFirstIncompletePreviousVideo(c.Context(), userID, video.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to validate video access", + Error: err.Error(), + }) + } + if blockedBy != nil { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "You must complete previous videos first", + Error: fmt.Sprintf("Complete video '%s' (display_order=%d) before accessing this one", blockedBy.Title, blockedBy.DisplayOrder), + }) + } + } + var publishDate *string if video.PublishDate != nil { pd := video.PublishDate.String() @@ -1163,6 +1249,74 @@ func (h *Handler) GetSubCourseVideoByID(c *fiber.Ctx) error { }) } +// CompleteSubCourseVideo godoc +// @Summary Mark sub-course video as completed +// @Description Marks the given video as completed for the authenticated learner +// @Tags progression +// @Produce json +// @Param id path int true "Video ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/progress/videos/{id}/complete [post] +func (h *Handler) CompleteSubCourseVideo(c *fiber.Ctx) error { + userID := c.Locals("user_id").(int64) + role := c.Locals("role").(domain.Role) + if role != domain.RoleStudent { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Only learners can complete videos", + }) + } + + videoID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid video ID", + Error: err.Error(), + }) + } + + video, err := h.courseMgmtSvc.GetSubCourseVideoByID(c.Context(), videoID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Video not found", + Error: err.Error(), + }) + } + if video.Status != string(domain.ContentStatusPublished) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Video not found", + }) + } + + blockedBy, err := h.courseMgmtSvc.GetFirstIncompletePreviousVideo(c.Context(), userID, videoID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to validate video completion", + Error: err.Error(), + }) + } + if blockedBy != nil { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "You must complete previous videos first", + Error: fmt.Sprintf("Complete video '%s' (display_order=%d) before completing this one", blockedBy.Title, blockedBy.DisplayOrder), + }) + } + + if err := h.courseMgmtSvc.MarkVideoCompleted(c.Context(), userID, videoID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to complete video", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Video completed", + }) +} + type getVideosBySubCourseRes struct { Videos []subCourseVideoRes `json:"videos"` TotalCount int64 `json:"total_count"` @@ -2024,7 +2178,7 @@ func (h *Handler) UploadSubCourseThumbnail(c *fiber.Ctx) error { return err } - if err := h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, nil, nil, &publicPath, nil, nil, nil); err != nil { + if err := h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, nil, nil, &publicPath, nil, nil, nil, nil); err != nil { _ = os.Remove(filepath.Join(".", publicPath)) return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update sub-course thumbnail", diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 34d21af..910c205 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "strconv" + "strings" "github.com/gofiber/fiber/v2" ) @@ -549,6 +550,40 @@ type listQuestionSetsRes struct { TotalCount int64 `json:"total_count"` } +func isSubCoursePractice(set domain.QuestionSet) bool { + return strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) && + set.OwnerType != nil && + strings.EqualFold(*set.OwnerType, "SUB_COURSE") +} + +func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error { + role := c.Locals("role").(domain.Role) + if role != domain.RoleStudent || !isSubCoursePractice(set) { + return nil + } + if !strings.EqualFold(set.Status, string(domain.ContentStatusPublished)) { + return fiber.NewError(fiber.StatusNotFound, "Practice not found") + } + + userID := c.Locals("user_id").(int64) + blockedBy, err := h.questionsSvc.GetFirstIncompletePreviousPractice(c.Context(), userID, set.ID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate practice access") + } + if blockedBy != nil { + return fiber.NewError( + fiber.StatusForbidden, + fmt.Sprintf( + "Complete practice '%s' (display_order=%d) before accessing this one", + blockedBy.Title, + blockedBy.DisplayOrder, + ), + ) + } + + return nil +} + // CreateQuestionSet godoc // @Summary Create a new question set // @Description Creates a new question set (practice, assessment, quiz, exam, or survey) @@ -569,6 +604,15 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error { }) } + if strings.EqualFold(req.SetType, string(domain.QuestionSetTypeInitialAssessment)) { + if req.OwnerType == nil || !strings.EqualFold(*req.OwnerType, "SUB_COURSE") || req.OwnerID == nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid initial assessment ownership", + Error: "INITIAL_ASSESSMENT question sets must include owner_type=SUB_COURSE and owner_id", + }) + } + } + input := domain.CreateQuestionSetInput{ Title: req.Title, Description: req.Description, @@ -620,6 +664,59 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error { }) } +// GetSubCourseEntryAssessmentSet godoc +// @Summary Get entry assessment set for a sub-course +// @Description Returns the published INITIAL_ASSESSMENT question set for the given sub-course +// @Tags question-sets +// @Produce json +// @Param subCourseId path int true "Sub-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/question-sets/sub-courses/{subCourseId}/entry-assessment [get] +func (h *Handler) GetSubCourseEntryAssessmentSet(c *fiber.Ctx) error { + subCourseIDStr := c.Params("subCourseId") + subCourseID, err := strconv.ParseInt(subCourseIDStr, 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid sub-course ID", + Error: err.Error(), + }) + } + + set, err := h.questionsSvc.GetSubCourseInitialAssessmentSet(c.Context(), subCourseID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Entry assessment set not found for sub-course", + Error: err.Error(), + }) + } + + count, _ := h.questionsSvc.CountQuestionsInSet(c.Context(), set.ID) + + return c.JSON(domain.Response{ + Message: "Entry assessment set retrieved successfully", + Data: questionSetRes{ + ID: set.ID, + Title: set.Title, + Description: set.Description, + SetType: set.SetType, + OwnerType: set.OwnerType, + OwnerID: set.OwnerID, + BannerImage: set.BannerImage, + Persona: set.Persona, + TimeLimitMinutes: set.TimeLimitMinutes, + PassingScore: set.PassingScore, + ShuffleQuestions: set.ShuffleQuestions, + Status: set.Status, + SubCourseVideoID: set.SubCourseVideoID, + CreatedAt: set.CreatedAt.String(), + QuestionCount: &count, + }, + }) +} + // GetQuestionSetByID godoc // @Summary Get question set by ID // @Description Returns a question set with question count @@ -648,6 +745,17 @@ func (h *Handler) GetQuestionSetByID(c *fiber.Ctx) error { }) } + if err := h.enforcePracticeSequenceForStudent(c, set); err != nil { + status := fiber.StatusForbidden + if ferr, ok := err.(*fiber.Error); ok { + status = ferr.Code + } + return c.Status(status).JSON(domain.ErrorResponse{ + Message: "Practice is locked", + Error: err.Error(), + }) + } + count, _ := h.questionsSvc.CountQuestionsInSet(c.Context(), id) return c.JSON(domain.Response{ @@ -1007,6 +1115,25 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error { }) } + set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), setID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Question set not found", + Error: err.Error(), + }) + } + + if err := h.enforcePracticeSequenceForStudent(c, set); err != nil { + status := fiber.StatusForbidden + if ferr, ok := err.(*fiber.Error); ok { + status = ferr.Code + } + return c.Status(status).JSON(domain.ErrorResponse{ + Message: "Practice is locked", + Error: err.Error(), + }) + } + items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), setID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ @@ -1039,6 +1166,67 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error { }) } +// CompletePractice godoc +// @Summary Mark practice as completed +// @Description Marks a practice question set as completed for the authenticated learner +// @Tags progression +// @Produce json +// @Param id path int true "Practice Question Set ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 403 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/progress/practices/{id}/complete [post] +func (h *Handler) CompletePractice(c *fiber.Ctx) error { + role := c.Locals("role").(domain.Role) + if role != domain.RoleStudent { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "Only learners can complete practices", + }) + } + userID := c.Locals("user_id").(int64) + + setID, err := strconv.ParseInt(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid practice ID", + Error: err.Error(), + }) + } + + set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), setID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Practice not found", + Error: err.Error(), + }) + } + if !isSubCoursePractice(set) || !strings.EqualFold(set.Status, string(domain.ContentStatusPublished)) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Practice not found", + }) + } + + if err := h.enforcePracticeSequenceForStudent(c, set); err != nil { + return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ + Message: "You must complete previous practices first", + Error: err.Error(), + }) + } + + if err := h.questionsSvc.MarkPracticeCompleted(c.Context(), userID, set.ID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to complete practice", + Error: err.Error(), + }) + } + + return c.JSON(domain.Response{ + Message: "Practice completed", + }) +} + // RemoveQuestionFromSet godoc // @Summary Remove question from set // @Description Unlinks a question from a question set diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 0ebed0d..bd614fa 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -134,6 +134,7 @@ func (a *App) initAppRoutes() { groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet) groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType) groupV1.Get("/question-sets/by-owner", a.authMiddleware, a.RequirePermission("question_sets.list_by_owner"), h.GetQuestionSetsByOwner) + groupV1.Get("/question-sets/sub-courses/:subCourseId/entry-assessment", a.authMiddleware, a.RequirePermission("question_sets.list_by_owner"), h.GetSubCourseEntryAssessmentSet) groupV1.Get("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetQuestionSetByID) groupV1.Put("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateQuestionSet) groupV1.Delete("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteQuestionSet) @@ -326,6 +327,8 @@ func (a *App) initAppRoutes() { groupV1.Post("/progress/sub-courses/:id/start", a.authMiddleware, a.RequirePermission("progress.start"), h.StartSubCourse) groupV1.Put("/progress/sub-courses/:id", a.authMiddleware, a.RequirePermission("progress.update"), h.UpdateSubCourseProgress) groupV1.Post("/progress/sub-courses/:id/complete", a.authMiddleware, a.RequirePermission("progress.complete"), h.CompleteSubCourse) + groupV1.Post("/progress/videos/:id/complete", a.authMiddleware, a.RequirePermission("progress.update"), h.CompleteSubCourseVideo) + groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequirePermission("progress.update"), h.CompletePractice) groupV1.Get("/progress/sub-courses/:id/access", a.authMiddleware, a.RequirePermission("progress.check_access"), h.CheckSubCourseAccess) groupV1.Get("/progress/courses/:courseId", a.authMiddleware, a.RequirePermission("progress.get_course"), h.GetUserCourseProgress)