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);
|
(3, 'React.js Masterclass', 'Build dynamic user interfaces with React', 'https://example.com/thumbnails/react.jpg', TRUE);
|
||||||
|
|
||||||
-- Sub-courses (replacing Programs/Levels hierarchy)
|
-- 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
|
-- Python Programming Fundamentals sub-courses
|
||||||
(1, 'Python Basics - Getting Started', 'Introduction to Python and basic syntax', NULL, 1, 'BEGINNER', 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', 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', 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', 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', TRUE),
|
(1, 'Python Advanced - Best Practices', 'Advanced Python concepts and best practices', NULL, 5, 'ADVANCED', 'C1', TRUE),
|
||||||
|
|
||||||
-- JavaScript sub-courses
|
-- JavaScript sub-courses
|
||||||
(2, 'JavaScript Fundamentals', 'Core JavaScript concepts and syntax', NULL, 1, 'BEGINNER', 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', TRUE),
|
(2, 'DOM Manipulation Basics', 'Working with the Document Object Model', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
|
||||||
|
|
||||||
-- Java sub-courses
|
-- Java sub-courses
|
||||||
(3, 'Java Core Concepts', 'Essential Java programming principles', NULL, 1, 'BEGINNER', 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', TRUE),
|
(3, 'Spring Framework Intro', 'Building enterprise applications with Spring', NULL, 2, 'ADVANCED', 'C1', TRUE),
|
||||||
|
|
||||||
-- Data Science sub-courses
|
-- Data Science sub-courses
|
||||||
(4, 'Data Analysis Fundamentals', 'Learn data manipulation with pandas', NULL, 1, 'BEGINNER', 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', TRUE),
|
(4, 'Advanced Data Analysis', 'Complex data transformations', NULL, 2, 'ADVANCED', 'C1', TRUE),
|
||||||
|
|
||||||
-- Machine Learning sub-courses
|
-- Machine Learning sub-courses
|
||||||
(5, 'ML Basics', 'Introduction to machine learning concepts', NULL, 1, 'BEGINNER', TRUE),
|
(5, 'ML Basics', 'Introduction to machine learning concepts', NULL, 1, 'BEGINNER', 'A1', TRUE),
|
||||||
(5, 'ML Algorithms', 'Understanding common ML algorithms', NULL, 2, 'INTERMEDIATE', TRUE),
|
(5, 'ML Algorithms', 'Understanding common ML algorithms', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
|
||||||
|
|
||||||
-- Full Stack Web Development sub-courses
|
-- Full Stack Web Development sub-courses
|
||||||
(6, 'Frontend Fundamentals', 'HTML, CSS, and JavaScript basics', NULL, 1, 'BEGINNER', TRUE),
|
(6, 'Frontend Fundamentals', 'HTML, CSS, and JavaScript basics', NULL, 1, 'BEGINNER', 'A1', TRUE),
|
||||||
(6, 'Backend Development', 'Server-side programming', NULL, 2, 'INTERMEDIATE', TRUE),
|
(6, 'Backend Development', 'Server-side programming', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
|
||||||
|
|
||||||
-- React.js sub-courses
|
-- React.js sub-courses
|
||||||
(7, 'React Basics', 'Core React concepts and JSX', NULL, 1, 'BEGINNER', 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', TRUE);
|
(7, 'React Advanced Patterns', 'Hooks, context, and performance', NULL, 2, 'ADVANCED', 'C1', TRUE);
|
||||||
|
|
||||||
-- Sub-course Videos
|
-- Sub-course Videos
|
||||||
INSERT INTO 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)
|
-- 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
|
-- Flutter sub-courses (course 8) — IDs 18-21
|
||||||
(18, 8, 'Dart Language Basics', 'Learn Dart programming language fundamentals', NULL, 1, 'BEGINNER', 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', 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', 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', 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
|
-- React Native sub-courses (course 9) — IDs 22-24
|
||||||
(22, 9, 'React Native Setup', 'Environment setup and first app', NULL, 1, 'BEGINNER', 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', 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', 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
|
-- Docker & Kubernetes sub-courses (course 10) — IDs 25-27
|
||||||
(25, 10, 'Docker Fundamentals', 'Containers, images, and Dockerfiles', NULL, 1, 'BEGINNER', TRUE),
|
(25, 10, 'Docker Fundamentals', 'Containers, images, and Dockerfiles', NULL, 1, 'BEGINNER', 'A1', TRUE),
|
||||||
(26, 10, 'Docker Compose', 'Multi-container applications', NULL, 2, 'INTERMEDIATE', TRUE),
|
(26, 10, 'Docker Compose', 'Multi-container applications', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
|
||||||
(27, 10, 'Kubernetes Basics', 'Pods, services, and deployments', NULL, 3, 'ADVANCED', TRUE),
|
(27, 10, 'Kubernetes Basics', 'Pods, services, and deployments', NULL, 3, 'ADVANCED', 'C1', TRUE),
|
||||||
|
|
||||||
-- CI/CD sub-courses (course 11) — IDs 28-29
|
-- CI/CD sub-courses (course 11) — IDs 28-29
|
||||||
(28, 11, 'Git Workflows', 'Branching strategies and pull requests', NULL, 1, 'BEGINNER', 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', TRUE),
|
(29, 11, 'GitHub Actions', 'Automate workflows with GitHub Actions', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
|
||||||
|
|
||||||
-- Cybersecurity sub-courses (course 12) — IDs 30-31
|
-- Cybersecurity sub-courses (course 12) — IDs 30-31
|
||||||
(30, 12, 'Network Security Basics', 'Firewalls, VPNs, and network protocols', NULL, 1, 'BEGINNER', 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', TRUE)
|
(31, 12, 'Penetration Testing', 'Tools and techniques for pen testing', NULL, 2, 'ADVANCED', 'C1', TRUE)
|
||||||
ON CONFLICT (id) DO NOTHING;
|
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')
|
(10, 'React Native Setup Quiz', 'Test React Native setup knowledge', 'PRACTICE', 'SUB_COURSE', 22, 'beginner', 'PUBLISHED')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
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
|
-- Link questions to question sets
|
||||||
INSERT INTO question_set_items (set_id, question_id, display_order) VALUES
|
INSERT INTO question_set_items (set_id, question_id, display_order) VALUES
|
||||||
(5, 21, 1),
|
(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,
|
c.title AS course_title,
|
||||||
sc.id AS sub_course_id,
|
sc.id AS sub_course_id,
|
||||||
sc.title AS sub_course_title,
|
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
|
FROM courses c
|
||||||
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
|
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
|
||||||
WHERE c.is_active = true
|
WHERE c.is_active = true
|
||||||
|
|
@ -25,6 +26,7 @@ SELECT
|
||||||
sc.thumbnail AS sub_course_thumbnail,
|
sc.thumbnail AS sub_course_thumbnail,
|
||||||
sc.display_order AS sub_course_display_order,
|
sc.display_order AS sub_course_display_order,
|
||||||
sc.level AS sub_course_level,
|
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_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 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
|
(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;
|
ORDER BY qs.display_order ASC, qs.created_at;
|
||||||
|
|
||||||
-- name: GetSubCoursePrerequisitesForLearningPath :many
|
-- 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
|
FROM sub_course_prerequisites p
|
||||||
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
|
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
|
||||||
WHERE p.sub_course_id = $1
|
WHERE p.sub_course_id = $1
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,16 @@ WHERE set_type = 'INITIAL_ASSESSMENT'
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1;
|
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
|
-- name: AddUserPersonaToQuestionSet :one
|
||||||
INSERT INTO question_set_personas (
|
INSERT INTO question_set_personas (
|
||||||
question_set_id,
|
question_set_id,
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ INSERT INTO sub_courses (
|
||||||
thumbnail,
|
thumbnail,
|
||||||
display_order,
|
display_order,
|
||||||
level,
|
level,
|
||||||
|
sub_level,
|
||||||
is_active
|
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 *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetSubCourseByID :one
|
-- name: GetSubCourseByID :one
|
||||||
|
|
@ -26,6 +27,7 @@ SELECT
|
||||||
thumbnail,
|
thumbnail,
|
||||||
display_order,
|
display_order,
|
||||||
level,
|
level,
|
||||||
|
sub_level,
|
||||||
is_active
|
is_active
|
||||||
FROM sub_courses
|
FROM sub_courses
|
||||||
WHERE course_id = $1
|
WHERE course_id = $1
|
||||||
|
|
@ -40,6 +42,7 @@ SELECT
|
||||||
thumbnail,
|
thumbnail,
|
||||||
display_order,
|
display_order,
|
||||||
level,
|
level,
|
||||||
|
sub_level,
|
||||||
is_active
|
is_active
|
||||||
FROM sub_courses
|
FROM sub_courses
|
||||||
WHERE course_id = $1
|
WHERE course_id = $1
|
||||||
|
|
@ -55,6 +58,7 @@ SELECT
|
||||||
thumbnail,
|
thumbnail,
|
||||||
display_order,
|
display_order,
|
||||||
level,
|
level,
|
||||||
|
sub_level,
|
||||||
is_active
|
is_active
|
||||||
FROM sub_courses
|
FROM sub_courses
|
||||||
WHERE is_active = TRUE
|
WHERE is_active = TRUE
|
||||||
|
|
@ -68,8 +72,9 @@ SET
|
||||||
thumbnail = COALESCE($3, thumbnail),
|
thumbnail = COALESCE($3, thumbnail),
|
||||||
display_order = COALESCE($4, display_order),
|
display_order = COALESCE($4, display_order),
|
||||||
level = COALESCE($5, level),
|
level = COALESCE($5, level),
|
||||||
is_active = COALESCE($6, is_active)
|
sub_level = COALESCE($6, sub_level),
|
||||||
WHERE id = $7;
|
is_active = COALESCE($7, is_active)
|
||||||
|
WHERE id = $8;
|
||||||
|
|
||||||
-- name: DeleteSubCourse :one
|
-- name: DeleteSubCourse :one
|
||||||
DELETE FROM sub_courses
|
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.thumbnail AS sub_course_thumbnail,
|
||||||
sc.display_order AS sub_course_display_order,
|
sc.display_order AS sub_course_display_order,
|
||||||
sc.level AS sub_course_level,
|
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_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 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
|
(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"`
|
SubCourseThumbnail pgtype.Text `json:"sub_course_thumbnail"`
|
||||||
SubCourseDisplayOrder pgtype.Int4 `json:"sub_course_display_order"`
|
SubCourseDisplayOrder pgtype.Int4 `json:"sub_course_display_order"`
|
||||||
SubCourseLevel pgtype.Text `json:"sub_course_level"`
|
SubCourseLevel pgtype.Text `json:"sub_course_level"`
|
||||||
|
SubCourseSubLevel pgtype.Text `json:"sub_course_sub_level"`
|
||||||
PrerequisiteCount int64 `json:"prerequisite_count"`
|
PrerequisiteCount int64 `json:"prerequisite_count"`
|
||||||
VideoCount int64 `json:"video_count"`
|
VideoCount int64 `json:"video_count"`
|
||||||
PracticeCount int64 `json:"practice_count"`
|
PracticeCount int64 `json:"practice_count"`
|
||||||
|
|
@ -78,6 +80,7 @@ func (q *Queries) GetCourseLearningPath(ctx context.Context, id int64) ([]GetCou
|
||||||
&i.SubCourseThumbnail,
|
&i.SubCourseThumbnail,
|
||||||
&i.SubCourseDisplayOrder,
|
&i.SubCourseDisplayOrder,
|
||||||
&i.SubCourseLevel,
|
&i.SubCourseLevel,
|
||||||
|
&i.SubCourseSubLevel,
|
||||||
&i.PrerequisiteCount,
|
&i.PrerequisiteCount,
|
||||||
&i.VideoCount,
|
&i.VideoCount,
|
||||||
&i.PracticeCount,
|
&i.PracticeCount,
|
||||||
|
|
@ -98,7 +101,8 @@ SELECT
|
||||||
c.title AS course_title,
|
c.title AS course_title,
|
||||||
sc.id AS sub_course_id,
|
sc.id AS sub_course_id,
|
||||||
sc.title AS sub_course_title,
|
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
|
FROM courses c
|
||||||
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
|
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
|
||||||
WHERE c.is_active = true
|
WHERE c.is_active = true
|
||||||
|
|
@ -111,6 +115,7 @@ type GetFullLearningTreeRow struct {
|
||||||
SubCourseID pgtype.Int8 `json:"sub_course_id"`
|
SubCourseID pgtype.Int8 `json:"sub_course_id"`
|
||||||
SubCourseTitle pgtype.Text `json:"sub_course_title"`
|
SubCourseTitle pgtype.Text `json:"sub_course_title"`
|
||||||
SubCourseLevel pgtype.Text `json:"sub_course_level"`
|
SubCourseLevel pgtype.Text `json:"sub_course_level"`
|
||||||
|
SubCourseSubLevel pgtype.Text `json:"sub_course_sub_level"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTreeRow, error) {
|
func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTreeRow, error) {
|
||||||
|
|
@ -128,6 +133,7 @@ func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTre
|
||||||
&i.SubCourseID,
|
&i.SubCourseID,
|
||||||
&i.SubCourseTitle,
|
&i.SubCourseTitle,
|
||||||
&i.SubCourseLevel,
|
&i.SubCourseLevel,
|
||||||
|
&i.SubCourseSubLevel,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -185,7 +191,7 @@ func (q *Queries) GetSubCoursePracticesForLearningPath(ctx context.Context, owne
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetSubCoursePrerequisitesForLearningPath = `-- name: GetSubCoursePrerequisitesForLearningPath :many
|
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
|
FROM sub_course_prerequisites p
|
||||||
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
|
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
|
||||||
WHERE p.sub_course_id = $1
|
WHERE p.sub_course_id = $1
|
||||||
|
|
@ -196,6 +202,7 @@ type GetSubCoursePrerequisitesForLearningPathRow struct {
|
||||||
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
|
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
|
SubLevel string `json:"sub_level"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetSubCoursePrerequisitesForLearningPath(ctx context.Context, subCourseID int64) ([]GetSubCoursePrerequisitesForLearningPathRow, error) {
|
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
|
var items []GetSubCoursePrerequisitesForLearningPathRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetSubCoursePrerequisitesForLearningPathRow
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,7 @@ type SubCourse struct {
|
||||||
DisplayOrder int32 `json:"display_order"`
|
DisplayOrder int32 `json:"display_order"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
|
SubLevel string `json:"sub_level"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubCoursePrerequisite struct {
|
type SubCoursePrerequisite struct {
|
||||||
|
|
@ -387,6 +388,16 @@ type User struct {
|
||||||
ProfileCompletionPercentage int16 `json:"profile_completion_percentage"`
|
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 {
|
type UserSubCourseProgress struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
|
|
@ -399,6 +410,16 @@ type UserSubCourseProgress struct {
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
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 {
|
type UserSubscription struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,41 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets
|
||||||
return items, nil
|
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
|
const GetUserPersonasByQuestionSetID = `-- name: GetUserPersonasByQuestionSetID :many
|
||||||
SELECT
|
SELECT
|
||||||
u.id,
|
u.id,
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,11 @@ INSERT INTO sub_courses (
|
||||||
thumbnail,
|
thumbnail,
|
||||||
display_order,
|
display_order,
|
||||||
level,
|
level,
|
||||||
|
sub_level,
|
||||||
is_active
|
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 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
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateSubCourseParams struct {
|
type CreateSubCourseParams struct {
|
||||||
|
|
@ -32,7 +33,8 @@ type CreateSubCourseParams struct {
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
Column5 interface{} `json:"column_5"`
|
Column5 interface{} `json:"column_5"`
|
||||||
Level string `json:"level"`
|
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) {
|
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.Thumbnail,
|
||||||
arg.Column5,
|
arg.Column5,
|
||||||
arg.Level,
|
arg.Level,
|
||||||
arg.Column7,
|
arg.SubLevel,
|
||||||
|
arg.Column8,
|
||||||
)
|
)
|
||||||
var i SubCourse
|
var i SubCourse
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
|
|
@ -55,6 +58,7 @@ func (q *Queries) CreateSubCourse(ctx context.Context, arg CreateSubCourseParams
|
||||||
&i.DisplayOrder,
|
&i.DisplayOrder,
|
||||||
&i.Level,
|
&i.Level,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
|
&i.SubLevel,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +77,7 @@ func (q *Queries) DeactivateSubCourse(ctx context.Context, id int64) error {
|
||||||
const DeleteSubCourse = `-- name: DeleteSubCourse :one
|
const DeleteSubCourse = `-- name: DeleteSubCourse :one
|
||||||
DELETE FROM sub_courses
|
DELETE FROM sub_courses
|
||||||
WHERE id = $1
|
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) {
|
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.DisplayOrder,
|
||||||
&i.Level,
|
&i.Level,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
|
&i.SubLevel,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const GetSubCourseByID = `-- name: GetSubCourseByID :one
|
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
|
FROM sub_courses
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
@ -110,6 +115,7 @@ func (q *Queries) GetSubCourseByID(ctx context.Context, id int64) (SubCourse, er
|
||||||
&i.DisplayOrder,
|
&i.DisplayOrder,
|
||||||
&i.Level,
|
&i.Level,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
|
&i.SubLevel,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +130,7 @@ SELECT
|
||||||
thumbnail,
|
thumbnail,
|
||||||
display_order,
|
display_order,
|
||||||
level,
|
level,
|
||||||
|
sub_level,
|
||||||
is_active
|
is_active
|
||||||
FROM sub_courses
|
FROM sub_courses
|
||||||
WHERE course_id = $1
|
WHERE course_id = $1
|
||||||
|
|
@ -139,6 +146,7 @@ type GetSubCoursesByCourseRow struct {
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
DisplayOrder int32 `json:"display_order"`
|
DisplayOrder int32 `json:"display_order"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
|
SubLevel string `json:"sub_level"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,6 +168,7 @@ func (q *Queries) GetSubCoursesByCourse(ctx context.Context, courseID int64) ([]
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
&i.DisplayOrder,
|
&i.DisplayOrder,
|
||||||
&i.Level,
|
&i.Level,
|
||||||
|
&i.SubLevel,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -181,21 +190,34 @@ SELECT
|
||||||
thumbnail,
|
thumbnail,
|
||||||
display_order,
|
display_order,
|
||||||
level,
|
level,
|
||||||
|
sub_level,
|
||||||
is_active
|
is_active
|
||||||
FROM sub_courses
|
FROM sub_courses
|
||||||
WHERE is_active = TRUE
|
WHERE is_active = TRUE
|
||||||
ORDER BY display_order ASC
|
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)
|
rows, err := q.db.Query(ctx, ListActiveSubCourses)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []SubCourse
|
var items []ListActiveSubCoursesRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i SubCourse
|
var i ListActiveSubCoursesRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.CourseID,
|
&i.CourseID,
|
||||||
|
|
@ -204,6 +226,7 @@ func (q *Queries) ListActiveSubCourses(ctx context.Context) ([]SubCourse, error)
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
&i.DisplayOrder,
|
&i.DisplayOrder,
|
||||||
&i.Level,
|
&i.Level,
|
||||||
|
&i.SubLevel,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -225,6 +248,7 @@ SELECT
|
||||||
thumbnail,
|
thumbnail,
|
||||||
display_order,
|
display_order,
|
||||||
level,
|
level,
|
||||||
|
sub_level,
|
||||||
is_active
|
is_active
|
||||||
FROM sub_courses
|
FROM sub_courses
|
||||||
WHERE course_id = $1
|
WHERE course_id = $1
|
||||||
|
|
@ -232,15 +256,27 @@ WHERE course_id = $1
|
||||||
ORDER BY display_order ASC, id ASC
|
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)
|
rows, err := q.db.Query(ctx, ListSubCoursesByCourse, courseID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []SubCourse
|
var items []ListSubCoursesByCourseRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i SubCourse
|
var i ListSubCoursesByCourseRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.CourseID,
|
&i.CourseID,
|
||||||
|
|
@ -249,6 +285,7 @@ func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
&i.DisplayOrder,
|
&i.DisplayOrder,
|
||||||
&i.Level,
|
&i.Level,
|
||||||
|
&i.SubLevel,
|
||||||
&i.IsActive,
|
&i.IsActive,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -288,8 +325,9 @@ SET
|
||||||
thumbnail = COALESCE($3, thumbnail),
|
thumbnail = COALESCE($3, thumbnail),
|
||||||
display_order = COALESCE($4, display_order),
|
display_order = COALESCE($4, display_order),
|
||||||
level = COALESCE($5, level),
|
level = COALESCE($5, level),
|
||||||
is_active = COALESCE($6, is_active)
|
sub_level = COALESCE($6, sub_level),
|
||||||
WHERE id = $7
|
is_active = COALESCE($7, is_active)
|
||||||
|
WHERE id = $8
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateSubCourseParams struct {
|
type UpdateSubCourseParams struct {
|
||||||
|
|
@ -298,6 +336,7 @@ type UpdateSubCourseParams struct {
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
DisplayOrder int32 `json:"display_order"`
|
DisplayOrder int32 `json:"display_order"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
|
SubLevel string `json:"sub_level"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
@ -309,6 +348,7 @@ func (q *Queries) UpdateSubCourse(ctx context.Context, arg UpdateSubCourseParams
|
||||||
arg.Thumbnail,
|
arg.Thumbnail,
|
||||||
arg.DisplayOrder,
|
arg.DisplayOrder,
|
||||||
arg.Level,
|
arg.Level,
|
||||||
|
arg.SubLevel,
|
||||||
arg.IsActive,
|
arg.IsActive,
|
||||||
arg.ID,
|
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"
|
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
|
type ContentStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -23,6 +37,7 @@ type TreeSubCourse struct {
|
||||||
ID int64
|
ID int64
|
||||||
Title string
|
Title string
|
||||||
Level string
|
Level string
|
||||||
|
SubLevel string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TreeCourse struct {
|
type TreeCourse struct {
|
||||||
|
|
@ -56,6 +71,7 @@ type SubCourse struct {
|
||||||
Thumbnail *string
|
Thumbnail *string
|
||||||
DisplayOrder int32
|
DisplayOrder int32
|
||||||
Level string
|
Level string
|
||||||
|
SubLevel string
|
||||||
IsActive bool
|
IsActive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,6 +104,12 @@ const (
|
||||||
VideoHostProviderVimeo VideoHostProvider = "VIMEO"
|
VideoHostProviderVimeo VideoHostProvider = "VIMEO"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type VideoAccessBlock struct {
|
||||||
|
VideoID int64
|
||||||
|
Title string
|
||||||
|
DisplayOrder int32
|
||||||
|
}
|
||||||
|
|
||||||
// Learning Path types — full nested structure for a course
|
// Learning Path types — full nested structure for a course
|
||||||
type LearningPathVideo struct {
|
type LearningPathVideo struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|
@ -115,6 +137,7 @@ type LearningPathPrerequisite struct {
|
||||||
SubCourseID int64 `json:"sub_course_id"`
|
SubCourseID int64 `json:"sub_course_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
|
SubLevel string `json:"sub_level"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LearningPathSubCourse struct {
|
type LearningPathSubCourse struct {
|
||||||
|
|
@ -124,6 +147,7 @@ type LearningPathSubCourse struct {
|
||||||
Thumbnail *string `json:"thumbnail,omitempty"`
|
Thumbnail *string `json:"thumbnail,omitempty"`
|
||||||
DisplayOrder int32 `json:"display_order"`
|
DisplayOrder int32 `json:"display_order"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
|
SubLevel string `json:"sub_level"`
|
||||||
PrerequisiteCount int64 `json:"prerequisite_count"`
|
PrerequisiteCount int64 `json:"prerequisite_count"`
|
||||||
VideoCount int64 `json:"video_count"`
|
VideoCount int64 `json:"video_count"`
|
||||||
PracticeCount int64 `json:"practice_count"`
|
PracticeCount int64 `json:"practice_count"`
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ const (
|
||||||
QuestionSetTypeSurvey QuestionSetType = "SURVEY"
|
QuestionSetTypeSurvey QuestionSetType = "SURVEY"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type PracticeAccessBlock struct {
|
||||||
|
QuestionSetID int64
|
||||||
|
Title string
|
||||||
|
DisplayOrder int32
|
||||||
|
}
|
||||||
|
|
||||||
type MatchType string
|
type MatchType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ type CourseStore interface {
|
||||||
thumbnail *string,
|
thumbnail *string,
|
||||||
displayOrder *int32,
|
displayOrder *int32,
|
||||||
level string,
|
level string,
|
||||||
|
subLevel string,
|
||||||
) (domain.SubCourse, error)
|
) (domain.SubCourse, error)
|
||||||
GetSubCourseByID(
|
GetSubCourseByID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|
@ -97,6 +98,7 @@ type CourseStore interface {
|
||||||
thumbnail *string,
|
thumbnail *string,
|
||||||
displayOrder *int32,
|
displayOrder *int32,
|
||||||
level *string,
|
level *string,
|
||||||
|
subLevel *string,
|
||||||
isActive *bool,
|
isActive *bool,
|
||||||
) error
|
) error
|
||||||
DeactivateSubCourse(
|
DeactivateSubCourse(
|
||||||
|
|
@ -140,6 +142,16 @@ type CourseStore interface {
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
subCourseID int64,
|
subCourseID int64,
|
||||||
) ([]domain.SubCourseVideo, error)
|
) ([]domain.SubCourseVideo, error)
|
||||||
|
GetFirstIncompletePreviousVideo(
|
||||||
|
ctx context.Context,
|
||||||
|
userID int64,
|
||||||
|
videoID int64,
|
||||||
|
) (*domain.VideoAccessBlock, error)
|
||||||
|
MarkVideoCompleted(
|
||||||
|
ctx context.Context,
|
||||||
|
userID int64,
|
||||||
|
videoID int64,
|
||||||
|
) error
|
||||||
PublishSubCourseVideo(
|
PublishSubCourseVideo(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
videoID int64,
|
videoID int64,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ type QuestionStore interface {
|
||||||
GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error)
|
GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error)
|
||||||
GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error)
|
GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error)
|
||||||
GetInitialAssessmentSet(ctx context.Context) (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
|
UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error
|
||||||
ArchiveQuestionSet(ctx context.Context, id int64) error
|
ArchiveQuestionSet(ctx context.Context, id int64) error
|
||||||
DeleteQuestionSet(ctx context.Context, id int64) error
|
DeleteQuestionSet(ctx context.Context, id int64) error
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ func (s *Store) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, e
|
||||||
ID: row.SubCourseID.Int64,
|
ID: row.SubCourseID.Int64,
|
||||||
Title: row.SubCourseTitle.String,
|
Title: row.SubCourseTitle.String,
|
||||||
Level: row.SubCourseLevel.String,
|
Level: row.SubCourseLevel.String,
|
||||||
|
SubLevel: row.SubCourseSubLevel.String,
|
||||||
}
|
}
|
||||||
course.SubCourses = append(course.SubCourses, subCourse)
|
course.SubCourses = append(course.SubCourses, subCourse)
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +86,7 @@ func (s *Store) GetCourseLearningPath(ctx context.Context, courseID int64) (doma
|
||||||
Thumbnail: ptrString(row.SubCourseThumbnail),
|
Thumbnail: ptrString(row.SubCourseThumbnail),
|
||||||
DisplayOrder: row.SubCourseDisplayOrder.Int32,
|
DisplayOrder: row.SubCourseDisplayOrder.Int32,
|
||||||
Level: row.SubCourseLevel.String,
|
Level: row.SubCourseLevel.String,
|
||||||
|
SubLevel: row.SubCourseSubLevel.String,
|
||||||
PrerequisiteCount: row.PrerequisiteCount,
|
PrerequisiteCount: row.PrerequisiteCount,
|
||||||
VideoCount: row.VideoCount,
|
VideoCount: row.VideoCount,
|
||||||
PracticeCount: row.PracticeCount,
|
PracticeCount: row.PracticeCount,
|
||||||
|
|
@ -109,6 +111,7 @@ func (s *Store) getSubCoursePrerequisitesForPath(ctx context.Context, subCourseI
|
||||||
SubCourseID: row.PrerequisiteSubCourseID,
|
SubCourseID: row.PrerequisiteSubCourseID,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
Level: row.Level,
|
Level: row.Level,
|
||||||
|
SubLevel: row.SubLevel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
dbgen "Yimaru-Backend/gen/db"
|
dbgen "Yimaru-Backend/gen/db"
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -632,6 +634,40 @@ func (s *Store) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet
|
||||||
return questionSetToDomain(qs), nil
|
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 {
|
func (s *Store) UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error {
|
||||||
var shuffleQuestions bool
|
var shuffleQuestions bool
|
||||||
if input.ShuffleQuestions != nil {
|
if input.ShuffleQuestions != nil {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import (
|
||||||
dbgen "Yimaru-Backend/gen/db"
|
dbgen "Yimaru-Backend/gen/db"
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -167,6 +169,41 @@ func (s *Store) GetPublishedVideosBySubCourse(
|
||||||
return videos, nil
|
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(
|
func (s *Store) PublishSubCourseVideo(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
videoID int64,
|
videoID int64,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ func (s *Store) CreateSubCourse(
|
||||||
thumbnail *string,
|
thumbnail *string,
|
||||||
displayOrder *int32,
|
displayOrder *int32,
|
||||||
level string,
|
level string,
|
||||||
|
subLevel string,
|
||||||
) (domain.SubCourse, error) {
|
) (domain.SubCourse, error) {
|
||||||
var descText, thumbText pgtype.Text
|
var descText, thumbText pgtype.Text
|
||||||
if description != nil {
|
if description != nil {
|
||||||
|
|
@ -37,7 +38,8 @@ func (s *Store) CreateSubCourse(
|
||||||
Thumbnail: thumbText,
|
Thumbnail: thumbText,
|
||||||
Column5: dispOrder,
|
Column5: dispOrder,
|
||||||
Level: level,
|
Level: level,
|
||||||
Column7: pgtype.Bool{Bool: true, Valid: true},
|
SubLevel: subLevel,
|
||||||
|
Column8: pgtype.Bool{Bool: true, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.SubCourse{}, err
|
return domain.SubCourse{}, err
|
||||||
|
|
@ -51,6 +53,7 @@ func (s *Store) CreateSubCourse(
|
||||||
Thumbnail: ptrString(row.Thumbnail),
|
Thumbnail: ptrString(row.Thumbnail),
|
||||||
DisplayOrder: row.DisplayOrder,
|
DisplayOrder: row.DisplayOrder,
|
||||||
Level: row.Level,
|
Level: row.Level,
|
||||||
|
SubLevel: row.SubLevel,
|
||||||
IsActive: row.IsActive,
|
IsActive: row.IsActive,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +75,7 @@ func (s *Store) GetSubCourseByID(
|
||||||
Thumbnail: ptrString(row.Thumbnail),
|
Thumbnail: ptrString(row.Thumbnail),
|
||||||
DisplayOrder: row.DisplayOrder,
|
DisplayOrder: row.DisplayOrder,
|
||||||
Level: row.Level,
|
Level: row.Level,
|
||||||
|
SubLevel: row.SubLevel,
|
||||||
IsActive: row.IsActive,
|
IsActive: row.IsActive,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -103,6 +107,7 @@ func (s *Store) GetSubCoursesByCourse(
|
||||||
Thumbnail: ptrString(row.Thumbnail),
|
Thumbnail: ptrString(row.Thumbnail),
|
||||||
DisplayOrder: row.DisplayOrder,
|
DisplayOrder: row.DisplayOrder,
|
||||||
Level: row.Level,
|
Level: row.Level,
|
||||||
|
SubLevel: row.SubLevel,
|
||||||
IsActive: row.IsActive,
|
IsActive: row.IsActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +134,7 @@ func (s *Store) ListSubCoursesByCourse(
|
||||||
Thumbnail: ptrString(row.Thumbnail),
|
Thumbnail: ptrString(row.Thumbnail),
|
||||||
DisplayOrder: row.DisplayOrder,
|
DisplayOrder: row.DisplayOrder,
|
||||||
Level: row.Level,
|
Level: row.Level,
|
||||||
|
SubLevel: row.SubLevel,
|
||||||
IsActive: row.IsActive,
|
IsActive: row.IsActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +160,7 @@ func (s *Store) ListActiveSubCourses(
|
||||||
Thumbnail: ptrString(row.Thumbnail),
|
Thumbnail: ptrString(row.Thumbnail),
|
||||||
DisplayOrder: row.DisplayOrder,
|
DisplayOrder: row.DisplayOrder,
|
||||||
Level: row.Level,
|
Level: row.Level,
|
||||||
|
SubLevel: row.SubLevel,
|
||||||
IsActive: row.IsActive,
|
IsActive: row.IsActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -169,9 +176,10 @@ func (s *Store) UpdateSubCourse(
|
||||||
thumbnail *string,
|
thumbnail *string,
|
||||||
displayOrder *int32,
|
displayOrder *int32,
|
||||||
level *string,
|
level *string,
|
||||||
|
subLevel *string,
|
||||||
isActive *bool,
|
isActive *bool,
|
||||||
) error {
|
) error {
|
||||||
var titleVal, descVal, thumbVal, levelVal string
|
var titleVal, descVal, thumbVal, levelVal, subLevelVal string
|
||||||
var dispOrderVal int32
|
var dispOrderVal int32
|
||||||
var isActiveVal bool
|
var isActiveVal bool
|
||||||
|
|
||||||
|
|
@ -190,6 +198,9 @@ func (s *Store) UpdateSubCourse(
|
||||||
if level != nil {
|
if level != nil {
|
||||||
levelVal = *level
|
levelVal = *level
|
||||||
}
|
}
|
||||||
|
if subLevel != nil {
|
||||||
|
subLevelVal = *subLevel
|
||||||
|
}
|
||||||
if isActive != nil {
|
if isActive != nil {
|
||||||
isActiveVal = *isActive
|
isActiveVal = *isActive
|
||||||
}
|
}
|
||||||
|
|
@ -200,6 +211,7 @@ func (s *Store) UpdateSubCourse(
|
||||||
Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil},
|
Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil},
|
||||||
DisplayOrder: dispOrderVal,
|
DisplayOrder: dispOrderVal,
|
||||||
Level: levelVal,
|
Level: levelVal,
|
||||||
|
SubLevel: subLevelVal,
|
||||||
IsActive: isActiveVal,
|
IsActive: isActiveVal,
|
||||||
ID: id,
|
ID: id,
|
||||||
})
|
})
|
||||||
|
|
@ -229,6 +241,7 @@ func (s *Store) DeleteSubCourse(
|
||||||
Thumbnail: ptrString(row.Thumbnail),
|
Thumbnail: ptrString(row.Thumbnail),
|
||||||
DisplayOrder: row.DisplayOrder,
|
DisplayOrder: row.DisplayOrder,
|
||||||
Level: row.Level,
|
Level: row.Level,
|
||||||
|
SubLevel: row.SubLevel,
|
||||||
IsActive: row.IsActive,
|
IsActive: row.IsActive,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,22 @@ func (s *Service) GetPublishedVideosBySubCourse(
|
||||||
return s.courseStore.GetPublishedVideosBySubCourse(ctx, subCourseID)
|
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(
|
func (s *Service) PublishSubCourseVideo(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
videoID int64,
|
videoID int64,
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@ func (s *Service) CreateSubCourse(
|
||||||
thumbnail *string,
|
thumbnail *string,
|
||||||
displayOrder *int32,
|
displayOrder *int32,
|
||||||
level string,
|
level string,
|
||||||
|
subLevel string,
|
||||||
) (domain.SubCourse, error) {
|
) (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(
|
func (s *Service) GetSubCourseByID(
|
||||||
|
|
@ -52,9 +53,10 @@ func (s *Service) UpdateSubCourse(
|
||||||
thumbnail *string,
|
thumbnail *string,
|
||||||
displayOrder *int32,
|
displayOrder *int32,
|
||||||
level *string,
|
level *string,
|
||||||
|
subLevel *string,
|
||||||
isActive *bool,
|
isActive *bool,
|
||||||
) error {
|
) 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(
|
func (s *Service) DeactivateSubCourse(
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,18 @@ func (s *Service) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionS
|
||||||
return s.questionStore.GetInitialAssessmentSet(ctx)
|
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 {
|
func (s *Service) UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error {
|
||||||
return s.questionStore.UpdateQuestionSet(ctx, id, input)
|
return s.questionStore.UpdateQuestionSet(ctx, id, input)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
@ -579,6 +580,7 @@ type createSubCourseReq struct {
|
||||||
Thumbnail *string `json:"thumbnail"`
|
Thumbnail *string `json:"thumbnail"`
|
||||||
DisplayOrder *int32 `json:"display_order"`
|
DisplayOrder *int32 `json:"display_order"`
|
||||||
Level string `json:"level" validate:"required"` // BEGINNER, INTERMEDIATE, ADVANCED
|
Level string `json:"level" validate:"required"` // BEGINNER, INTERMEDIATE, ADVANCED
|
||||||
|
SubLevel string `json:"sub_level" validate:"required"` // A1..C3 depending on level
|
||||||
}
|
}
|
||||||
|
|
||||||
type subCourseRes struct {
|
type subCourseRes struct {
|
||||||
|
|
@ -589,9 +591,29 @@ type subCourseRes struct {
|
||||||
Thumbnail *string `json:"thumbnail"`
|
Thumbnail *string `json:"thumbnail"`
|
||||||
DisplayOrder int32 `json:"display_order"`
|
DisplayOrder int32 `json:"display_order"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
|
SubLevel string `json:"sub_level"`
|
||||||
IsActive bool `json:"is_active"`
|
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
|
// CreateSubCourse godoc
|
||||||
// @Summary Create a new sub-course
|
// @Summary Create a new sub-course
|
||||||
// @Description Creates a new sub-course under a specific 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 {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to create sub-course",
|
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))
|
actorRole := string(c.Locals("role").(domain.Role))
|
||||||
ip := c.IP()
|
ip := c.IP()
|
||||||
ua := c.Get("User-Agent")
|
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 h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseCreated, domain.ResourceSubCourse, &subCourse.ID, "Created sub-course: "+subCourse.Title, meta, &ip, &ua)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -647,6 +676,7 @@ func (h *Handler) CreateSubCourse(c *fiber.Ctx) error {
|
||||||
Thumbnail: subCourse.Thumbnail,
|
Thumbnail: subCourse.Thumbnail,
|
||||||
DisplayOrder: subCourse.DisplayOrder,
|
DisplayOrder: subCourse.DisplayOrder,
|
||||||
Level: subCourse.Level,
|
Level: subCourse.Level,
|
||||||
|
SubLevel: subCourse.SubLevel,
|
||||||
IsActive: subCourse.IsActive,
|
IsActive: subCourse.IsActive,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -691,6 +721,7 @@ func (h *Handler) GetSubCourseByID(c *fiber.Ctx) error {
|
||||||
Thumbnail: subCourse.Thumbnail,
|
Thumbnail: subCourse.Thumbnail,
|
||||||
DisplayOrder: subCourse.DisplayOrder,
|
DisplayOrder: subCourse.DisplayOrder,
|
||||||
Level: subCourse.Level,
|
Level: subCourse.Level,
|
||||||
|
SubLevel: subCourse.SubLevel,
|
||||||
IsActive: subCourse.IsActive,
|
IsActive: subCourse.IsActive,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -739,6 +770,7 @@ func (h *Handler) GetSubCoursesByCourse(c *fiber.Ctx) error {
|
||||||
Thumbnail: sc.Thumbnail,
|
Thumbnail: sc.Thumbnail,
|
||||||
DisplayOrder: sc.DisplayOrder,
|
DisplayOrder: sc.DisplayOrder,
|
||||||
Level: sc.Level,
|
Level: sc.Level,
|
||||||
|
SubLevel: sc.SubLevel,
|
||||||
IsActive: sc.IsActive,
|
IsActive: sc.IsActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -790,6 +822,7 @@ func (h *Handler) ListSubCoursesByCourse(c *fiber.Ctx) error {
|
||||||
Thumbnail: sc.Thumbnail,
|
Thumbnail: sc.Thumbnail,
|
||||||
DisplayOrder: sc.DisplayOrder,
|
DisplayOrder: sc.DisplayOrder,
|
||||||
Level: sc.Level,
|
Level: sc.Level,
|
||||||
|
SubLevel: sc.SubLevel,
|
||||||
IsActive: sc.IsActive,
|
IsActive: sc.IsActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -827,6 +860,7 @@ func (h *Handler) ListActiveSubCourses(c *fiber.Ctx) error {
|
||||||
Thumbnail: sc.Thumbnail,
|
Thumbnail: sc.Thumbnail,
|
||||||
DisplayOrder: sc.DisplayOrder,
|
DisplayOrder: sc.DisplayOrder,
|
||||||
Level: sc.Level,
|
Level: sc.Level,
|
||||||
|
SubLevel: sc.SubLevel,
|
||||||
IsActive: sc.IsActive,
|
IsActive: sc.IsActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -843,6 +877,7 @@ type updateSubCourseReq struct {
|
||||||
Thumbnail *string `json:"thumbnail"`
|
Thumbnail *string `json:"thumbnail"`
|
||||||
DisplayOrder *int32 `json:"display_order"`
|
DisplayOrder *int32 `json:"display_order"`
|
||||||
Level *string `json:"level"`
|
Level *string `json:"level"`
|
||||||
|
SubLevel *string `json:"sub_level"`
|
||||||
IsActive *bool `json:"is_active"`
|
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 {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to update sub-course",
|
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))
|
actorRole := string(c.Locals("role").(domain.Role))
|
||||||
ip := c.IP()
|
ip := c.IP()
|
||||||
ua := c.Get("User-Agent")
|
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)
|
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{
|
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
|
var publishDate *string
|
||||||
if video.PublishDate != nil {
|
if video.PublishDate != nil {
|
||||||
pd := video.PublishDate.String()
|
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 {
|
type getVideosBySubCourseRes struct {
|
||||||
Videos []subCourseVideoRes `json:"videos"`
|
Videos []subCourseVideoRes `json:"videos"`
|
||||||
TotalCount int64 `json:"total_count"`
|
TotalCount int64 `json:"total_count"`
|
||||||
|
|
@ -2024,7 +2178,7 @@ func (h *Handler) UploadSubCourseThumbnail(c *fiber.Ctx) error {
|
||||||
return err
|
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))
|
_ = os.Remove(filepath.Join(".", publicPath))
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||||
Message: "Failed to update sub-course thumbnail",
|
Message: "Failed to update sub-course thumbnail",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
@ -549,6 +550,40 @@ type listQuestionSetsRes struct {
|
||||||
TotalCount int64 `json:"total_count"`
|
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
|
// CreateQuestionSet godoc
|
||||||
// @Summary Create a new question set
|
// @Summary Create a new question set
|
||||||
// @Description Creates a new question set (practice, assessment, quiz, exam, or survey)
|
// @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{
|
input := domain.CreateQuestionSetInput{
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Description: req.Description,
|
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
|
// GetQuestionSetByID godoc
|
||||||
// @Summary Get question set by ID
|
// @Summary Get question set by ID
|
||||||
// @Description Returns a question set with question count
|
// @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)
|
count, _ := h.questionsSvc.CountQuestionsInSet(c.Context(), id)
|
||||||
|
|
||||||
return c.JSON(domain.Response{
|
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)
|
items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), setID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
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
|
// RemoveQuestionFromSet godoc
|
||||||
// @Summary Remove question from set
|
// @Summary Remove question from set
|
||||||
// @Description Unlinks a question from a question 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.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", 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/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.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.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)
|
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.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.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/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/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)
|
groupV1.Get("/progress/courses/:courseId", a.authMiddleware, a.RequirePermission("progress.get_course"), h.GetUserCourseProgress)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user