learning flow + level + sublevel

This commit is contained in:
Yared Yemane 2026-03-08 05:35:17 -07:00
parent 3500db6435
commit 74efcd5ec2
34 changed files with 1537 additions and 75 deletions

View File

@ -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 (

View File

@ -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),

View File

@ -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;

View File

@ -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();

View 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;

View 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);

View 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;

View 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);

View File

@ -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

View File

@ -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,

View File

@ -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

View 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;

View 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;

View 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

View File

@ -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)

View File

@ -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"`

View File

@ -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,

View File

@ -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,
) )

View 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
}

View 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
}

View File

@ -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"`

View File

@ -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 (

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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,

View File

@ -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
} }

View File

@ -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,

View File

@ -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(

View File

@ -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)
} }

View File

@ -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",

View File

@ -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

View File

@ -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)