learning flow + level + sublevel
This commit is contained in:
parent
3500db6435
commit
74efcd5ec2
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
4
db/migrations/000025_video_sequence_progress.down.sql
Normal file
4
db/migrations/000025_video_sequence_progress.down.sql
Normal file
|
|
@ -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;
|
||||
15
db/migrations/000025_video_sequence_progress.up.sql
Normal file
15
db/migrations/000025_video_sequence_progress.up.sql
Normal file
|
|
@ -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);
|
||||
4
db/migrations/000026_practice_sequence_progress.down.sql
Normal file
4
db/migrations/000026_practice_sequence_progress.down.sql
Normal file
|
|
@ -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;
|
||||
15
db/migrations/000026_practice_sequence_progress.up.sql
Normal file
15
db/migrations/000026_practice_sequence_progress.up.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
51
db/query/user_practice_progress.sql
Normal file
51
db/query/user_practice_progress.sql
Normal file
|
|
@ -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;
|
||||
44
db/query/user_sub_course_video_progress.sql
Normal file
44
db/query/user_sub_course_video_progress.sql
Normal file
|
|
@ -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;
|
||||
300
docs/LEARNING_TREE_DRAG_DROP_INTEGRATION.md
Normal file
300
docs/LEARNING_TREE_DRAG_DROP_INTEGRATION.md
Normal file
|
|
@ -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 <TOKEN>" \
|
||||
-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 <TOKEN>" \
|
||||
-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 <TOKEN>"
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
102
gen/db/user_practice_progress.sql.go
Normal file
102
gen/db/user_practice_progress.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
95
gen/db/user_sub_course_video_progress.sql.go
Normal file
95
gen/db/user_sub_course_video_progress.sql.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ const (
|
|||
QuestionSetTypeSurvey QuestionSetType = "SURVEY"
|
||||
)
|
||||
|
||||
type PracticeAccessBlock struct {
|
||||
QuestionSetID int64
|
||||
Title string
|
||||
DisplayOrder int32
|
||||
}
|
||||
|
||||
type MatchType string
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user