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);
-- Sub-courses (replacing Programs/Levels hierarchy)
INSERT INTO sub_courses (course_id, title, description, thumbnail, display_order, level, is_active) VALUES
INSERT INTO sub_courses (course_id, title, description, thumbnail, display_order, level, sub_level, is_active) VALUES
-- Python Programming Fundamentals sub-courses
(1, 'Python Basics - Getting Started', 'Introduction to Python and basic syntax', NULL, 1, 'BEGINNER', TRUE),
(1, 'Python Basics - Data Types', 'Understanding Python data types and variables', NULL, 2, 'BEGINNER', TRUE),
(1, 'Python Intermediate - Functions', 'Writing and using functions in Python', NULL, 3, 'INTERMEDIATE', TRUE),
(1, 'Python Intermediate - Collections', 'Working with Python collections', NULL, 4, 'INTERMEDIATE', TRUE),
(1, 'Python Advanced - Best Practices', 'Advanced Python concepts and best practices', NULL, 5, 'ADVANCED', TRUE),
(1, 'Python Basics - Getting Started', 'Introduction to Python and basic syntax', NULL, 1, 'BEGINNER', 'A1', TRUE),
(1, 'Python Basics - Data Types', 'Understanding Python data types and variables', NULL, 2, 'BEGINNER', 'A2', TRUE),
(1, 'Python Intermediate - Functions', 'Writing and using functions in Python', NULL, 3, 'INTERMEDIATE', 'B1', TRUE),
(1, 'Python Intermediate - Collections', 'Working with Python collections', NULL, 4, 'INTERMEDIATE', 'B2', TRUE),
(1, 'Python Advanced - Best Practices', 'Advanced Python concepts and best practices', NULL, 5, 'ADVANCED', 'C1', TRUE),
-- JavaScript sub-courses
(2, 'JavaScript Fundamentals', 'Core JavaScript concepts and syntax', NULL, 1, 'BEGINNER', TRUE),
(2, 'DOM Manipulation Basics', 'Working with the Document Object Model', NULL, 2, 'INTERMEDIATE', TRUE),
(2, 'JavaScript Fundamentals', 'Core JavaScript concepts and syntax', NULL, 1, 'BEGINNER', 'A1', TRUE),
(2, 'DOM Manipulation Basics', 'Working with the Document Object Model', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
-- Java sub-courses
(3, 'Java Core Concepts', 'Essential Java programming principles', NULL, 1, 'BEGINNER', TRUE),
(3, 'Spring Framework Intro', 'Building enterprise applications with Spring', NULL, 2, 'ADVANCED', TRUE),
(3, 'Java Core Concepts', 'Essential Java programming principles', NULL, 1, 'BEGINNER', 'A1', TRUE),
(3, 'Spring Framework Intro', 'Building enterprise applications with Spring', NULL, 2, 'ADVANCED', 'C1', TRUE),
-- Data Science sub-courses
(4, 'Data Analysis Fundamentals', 'Learn data manipulation with pandas', NULL, 1, 'BEGINNER', TRUE),
(4, 'Advanced Data Analysis', 'Complex data transformations', NULL, 2, 'ADVANCED', TRUE),
(4, 'Data Analysis Fundamentals', 'Learn data manipulation with pandas', NULL, 1, 'BEGINNER', 'A1', TRUE),
(4, 'Advanced Data Analysis', 'Complex data transformations', NULL, 2, 'ADVANCED', 'C1', TRUE),
-- Machine Learning sub-courses
(5, 'ML Basics', 'Introduction to machine learning concepts', NULL, 1, 'BEGINNER', TRUE),
(5, 'ML Algorithms', 'Understanding common ML algorithms', NULL, 2, 'INTERMEDIATE', TRUE),
(5, 'ML Basics', 'Introduction to machine learning concepts', NULL, 1, 'BEGINNER', 'A1', TRUE),
(5, 'ML Algorithms', 'Understanding common ML algorithms', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
-- Full Stack Web Development sub-courses
(6, 'Frontend Fundamentals', 'HTML, CSS, and JavaScript basics', NULL, 1, 'BEGINNER', TRUE),
(6, 'Backend Development', 'Server-side programming', NULL, 2, 'INTERMEDIATE', TRUE),
(6, 'Frontend Fundamentals', 'HTML, CSS, and JavaScript basics', NULL, 1, 'BEGINNER', 'A1', TRUE),
(6, 'Backend Development', 'Server-side programming', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
-- React.js sub-courses
(7, 'React Basics', 'Core React concepts and JSX', NULL, 1, 'BEGINNER', TRUE),
(7, 'React Advanced Patterns', 'Hooks, context, and performance', NULL, 2, 'ADVANCED', TRUE);
(7, 'React Basics', 'Core React concepts and JSX', NULL, 1, 'BEGINNER', 'A1', TRUE),
(7, 'React Advanced Patterns', 'Hooks, context, and performance', NULL, 2, 'ADVANCED', 'C1', TRUE);
-- Sub-course Videos
INSERT INTO sub_course_videos (

View File

@ -30,30 +30,30 @@ ON CONFLICT (id) DO NOTHING;
-- ======================================================
-- Sub-courses (supplement existing 17 sub-courses: IDs 1-17)
-- ======================================================
INSERT INTO sub_courses (id, course_id, title, description, thumbnail, display_order, level, is_active) VALUES
INSERT INTO sub_courses (id, course_id, title, description, thumbnail, display_order, level, sub_level, is_active) VALUES
-- Flutter sub-courses (course 8) — IDs 18-21
(18, 8, 'Dart Language Basics', 'Learn Dart programming language fundamentals', NULL, 1, 'BEGINNER', TRUE),
(19, 8, 'Flutter UI Widgets', 'Build beautiful UIs with Flutter widgets', NULL, 2, 'BEGINNER', TRUE),
(20, 8, 'State Management', 'Manage app state with Provider and Riverpod', NULL, 3, 'INTERMEDIATE', TRUE),
(21, 8, 'Flutter Networking & APIs', 'HTTP requests, REST APIs, and data persistence', NULL, 4, 'ADVANCED', TRUE),
(18, 8, 'Dart Language Basics', 'Learn Dart programming language fundamentals', NULL, 1, 'BEGINNER', 'A1', TRUE),
(19, 8, 'Flutter UI Widgets', 'Build beautiful UIs with Flutter widgets', NULL, 2, 'BEGINNER', 'A2', TRUE),
(20, 8, 'State Management', 'Manage app state with Provider and Riverpod', NULL, 3, 'INTERMEDIATE', 'B1', TRUE),
(21, 8, 'Flutter Networking & APIs', 'HTTP requests, REST APIs, and data persistence', NULL, 4, 'ADVANCED', 'C1', TRUE),
-- React Native sub-courses (course 9) — IDs 22-24
(22, 9, 'React Native Setup', 'Environment setup and first app', NULL, 1, 'BEGINNER', TRUE),
(23, 9, 'Navigation & Routing', 'React Navigation and screen management', NULL, 2, 'INTERMEDIATE', TRUE),
(24, 9, 'Native Modules', 'Bridge native code with React Native', NULL, 3, 'ADVANCED', TRUE),
(22, 9, 'React Native Setup', 'Environment setup and first app', NULL, 1, 'BEGINNER', 'A1', TRUE),
(23, 9, 'Navigation & Routing', 'React Navigation and screen management', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
(24, 9, 'Native Modules', 'Bridge native code with React Native', NULL, 3, 'ADVANCED', 'C1', TRUE),
-- Docker & Kubernetes sub-courses (course 10) — IDs 25-27
(25, 10, 'Docker Fundamentals', 'Containers, images, and Dockerfiles', NULL, 1, 'BEGINNER', TRUE),
(26, 10, 'Docker Compose', 'Multi-container applications', NULL, 2, 'INTERMEDIATE', TRUE),
(27, 10, 'Kubernetes Basics', 'Pods, services, and deployments', NULL, 3, 'ADVANCED', TRUE),
(25, 10, 'Docker Fundamentals', 'Containers, images, and Dockerfiles', NULL, 1, 'BEGINNER', 'A1', TRUE),
(26, 10, 'Docker Compose', 'Multi-container applications', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
(27, 10, 'Kubernetes Basics', 'Pods, services, and deployments', NULL, 3, 'ADVANCED', 'C1', TRUE),
-- CI/CD sub-courses (course 11) — IDs 28-29
(28, 11, 'Git Workflows', 'Branching strategies and pull requests', NULL, 1, 'BEGINNER', TRUE),
(29, 11, 'GitHub Actions', 'Automate workflows with GitHub Actions', NULL, 2, 'INTERMEDIATE', TRUE),
(28, 11, 'Git Workflows', 'Branching strategies and pull requests', NULL, 1, 'BEGINNER', 'A1', TRUE),
(29, 11, 'GitHub Actions', 'Automate workflows with GitHub Actions', NULL, 2, 'INTERMEDIATE', 'B1', TRUE),
-- Cybersecurity sub-courses (course 12) — IDs 30-31
(30, 12, 'Network Security Basics', 'Firewalls, VPNs, and network protocols', NULL, 1, 'BEGINNER', TRUE),
(31, 12, 'Penetration Testing', 'Tools and techniques for pen testing', NULL, 2, 'ADVANCED', TRUE)
(30, 12, 'Network Security Basics', 'Firewalls, VPNs, and network protocols', NULL, 1, 'BEGINNER', 'A1', TRUE),
(31, 12, 'Penetration Testing', 'Tools and techniques for pen testing', NULL, 2, 'ADVANCED', 'C1', TRUE)
ON CONFLICT (id) DO NOTHING;
-- ======================================================
@ -180,6 +180,44 @@ INSERT INTO question_sets (id, title, description, set_type, owner_type, owner_i
(10, 'React Native Setup Quiz', 'Test React Native setup knowledge', 'PRACTICE', 'SUB_COURSE', 22, 'beginner', 'PUBLISHED')
ON CONFLICT (id) DO NOTHING;
-- Ensure every sub-course has at least one practice set
INSERT INTO question_sets (title, description, set_type, owner_type, owner_id, status)
SELECT
sc.title || ' Practice',
'Default practice set for ' || sc.title,
'PRACTICE',
'SUB_COURSE',
sc.id,
'DRAFT'
FROM sub_courses sc
WHERE NOT EXISTS (
SELECT 1
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.owner_id = sc.id
AND qs.set_type = 'PRACTICE'
AND qs.status != 'ARCHIVED'
);
-- Ensure every sub-course has one initial assessment set
INSERT INTO question_sets (title, description, set_type, owner_type, owner_id, status)
SELECT
sc.title || ' Entry Assessment',
'Initial assessment used before learners start ' || sc.title,
'INITIAL_ASSESSMENT',
'SUB_COURSE',
sc.id,
'DRAFT'
FROM sub_courses sc
WHERE NOT EXISTS (
SELECT 1
FROM question_sets qs
WHERE qs.owner_type = 'SUB_COURSE'
AND qs.owner_id = sc.id
AND qs.set_type = 'INITIAL_ASSESSMENT'
AND qs.status != 'ARCHIVED'
);
-- Link questions to question sets
INSERT INTO question_set_items (set_id, question_id, display_order) VALUES
(5, 21, 1),

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,
sc.id AS sub_course_id,
sc.title AS sub_course_title,
sc.level AS sub_course_level
sc.level AS sub_course_level,
sc.sub_level AS sub_course_sub_level
FROM courses c
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.is_active = true
@ -25,6 +26,7 @@ SELECT
sc.thumbnail AS sub_course_thumbnail,
sc.display_order AS sub_course_display_order,
sc.level AS sub_course_level,
sc.sub_level AS sub_course_sub_level,
(SELECT COUNT(*) FROM sub_course_prerequisites WHERE sub_course_id = sc.id) AS prerequisite_count,
(SELECT COUNT(*) FROM sub_course_videos WHERE sub_course_id = sc.id AND status = 'PUBLISHED') AS video_count,
(SELECT COUNT(*) FROM question_sets WHERE set_type = 'PRACTICE' AND owner_type = 'SUB_COURSE' AND owner_id = sc.id AND status = 'PUBLISHED') AS practice_count
@ -50,7 +52,7 @@ WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1
ORDER BY qs.display_order ASC, qs.created_at;
-- name: GetSubCoursePrerequisitesForLearningPath :many
SELECT p.prerequisite_sub_course_id, sc.title, sc.level
SELECT p.prerequisite_sub_course_id, sc.title, sc.level, sc.sub_level
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
WHERE p.sub_course_id = $1

View File

@ -80,6 +80,16 @@ WHERE set_type = 'INITIAL_ASSESSMENT'
ORDER BY created_at DESC
LIMIT 1;
-- name: GetSubCourseInitialAssessmentSet :one
SELECT *
FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT'
AND owner_type = 'SUB_COURSE'
AND owner_id = $1
AND status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 1;
-- name: AddUserPersonaToQuestionSet :one
INSERT INTO question_set_personas (
question_set_id,

View File

@ -6,9 +6,10 @@ INSERT INTO sub_courses (
thumbnail,
display_order,
level,
sub_level,
is_active
)
VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, COALESCE($7, true))
VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, $7, COALESCE($8, true))
RETURNING *;
-- name: GetSubCourseByID :one
@ -26,6 +27,7 @@ SELECT
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE course_id = $1
@ -40,6 +42,7 @@ SELECT
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE course_id = $1
@ -55,6 +58,7 @@ SELECT
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE is_active = TRUE
@ -68,8 +72,9 @@ SET
thumbnail = COALESCE($3, thumbnail),
display_order = COALESCE($4, display_order),
level = COALESCE($5, level),
is_active = COALESCE($6, is_active)
WHERE id = $7;
sub_level = COALESCE($6, sub_level),
is_active = COALESCE($7, is_active)
WHERE id = $8;
-- name: DeleteSubCourse :one
DELETE FROM sub_courses

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.display_order AS sub_course_display_order,
sc.level AS sub_course_level,
sc.sub_level AS sub_course_sub_level,
(SELECT COUNT(*) FROM sub_course_prerequisites WHERE sub_course_id = sc.id) AS prerequisite_count,
(SELECT COUNT(*) FROM sub_course_videos WHERE sub_course_id = sc.id AND status = 'PUBLISHED') AS video_count,
(SELECT COUNT(*) FROM question_sets WHERE set_type = 'PRACTICE' AND owner_type = 'SUB_COURSE' AND owner_id = sc.id AND status = 'PUBLISHED') AS practice_count
@ -50,6 +51,7 @@ type GetCourseLearningPathRow struct {
SubCourseThumbnail pgtype.Text `json:"sub_course_thumbnail"`
SubCourseDisplayOrder pgtype.Int4 `json:"sub_course_display_order"`
SubCourseLevel pgtype.Text `json:"sub_course_level"`
SubCourseSubLevel pgtype.Text `json:"sub_course_sub_level"`
PrerequisiteCount int64 `json:"prerequisite_count"`
VideoCount int64 `json:"video_count"`
PracticeCount int64 `json:"practice_count"`
@ -78,6 +80,7 @@ func (q *Queries) GetCourseLearningPath(ctx context.Context, id int64) ([]GetCou
&i.SubCourseThumbnail,
&i.SubCourseDisplayOrder,
&i.SubCourseLevel,
&i.SubCourseSubLevel,
&i.PrerequisiteCount,
&i.VideoCount,
&i.PracticeCount,
@ -98,7 +101,8 @@ SELECT
c.title AS course_title,
sc.id AS sub_course_id,
sc.title AS sub_course_title,
sc.level AS sub_course_level
sc.level AS sub_course_level,
sc.sub_level AS sub_course_sub_level
FROM courses c
LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true
WHERE c.is_active = true
@ -106,11 +110,12 @@ ORDER BY c.id, sc.display_order, sc.id
`
type GetFullLearningTreeRow struct {
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
SubCourseID pgtype.Int8 `json:"sub_course_id"`
SubCourseTitle pgtype.Text `json:"sub_course_title"`
SubCourseLevel pgtype.Text `json:"sub_course_level"`
CourseID int64 `json:"course_id"`
CourseTitle string `json:"course_title"`
SubCourseID pgtype.Int8 `json:"sub_course_id"`
SubCourseTitle pgtype.Text `json:"sub_course_title"`
SubCourseLevel pgtype.Text `json:"sub_course_level"`
SubCourseSubLevel pgtype.Text `json:"sub_course_sub_level"`
}
func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTreeRow, error) {
@ -128,6 +133,7 @@ func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTre
&i.SubCourseID,
&i.SubCourseTitle,
&i.SubCourseLevel,
&i.SubCourseSubLevel,
); err != nil {
return nil, err
}
@ -185,7 +191,7 @@ func (q *Queries) GetSubCoursePracticesForLearningPath(ctx context.Context, owne
}
const GetSubCoursePrerequisitesForLearningPath = `-- name: GetSubCoursePrerequisitesForLearningPath :many
SELECT p.prerequisite_sub_course_id, sc.title, sc.level
SELECT p.prerequisite_sub_course_id, sc.title, sc.level, sc.sub_level
FROM sub_course_prerequisites p
JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id
WHERE p.sub_course_id = $1
@ -196,6 +202,7 @@ type GetSubCoursePrerequisitesForLearningPathRow struct {
PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"`
Title string `json:"title"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
}
func (q *Queries) GetSubCoursePrerequisitesForLearningPath(ctx context.Context, subCourseID int64) ([]GetSubCoursePrerequisitesForLearningPathRow, error) {
@ -207,7 +214,12 @@ func (q *Queries) GetSubCoursePrerequisitesForLearningPath(ctx context.Context,
var items []GetSubCoursePrerequisitesForLearningPathRow
for rows.Next() {
var i GetSubCoursePrerequisitesForLearningPathRow
if err := rows.Scan(&i.PrerequisiteSubCourseID, &i.Title, &i.Level); err != nil {
if err := rows.Scan(
&i.PrerequisiteSubCourseID,
&i.Title,
&i.Level,
&i.SubLevel,
); err != nil {
return nil, err
}
items = append(items, i)

View File

@ -276,6 +276,7 @@ type SubCourse struct {
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
IsActive bool `json:"is_active"`
SubLevel string `json:"sub_level"`
}
type SubCoursePrerequisite struct {
@ -387,6 +388,16 @@ type User struct {
ProfileCompletionPercentage int16 `json:"profile_completion_percentage"`
}
type UserPracticeProgress struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
QuestionSetID int64 `json:"question_set_id"`
CompletedAt pgtype.Timestamp `json:"completed_at"`
CreatedAt pgtype.Timestamp `json:"created_at"`
UpdatedAt pgtype.Timestamp `json:"updated_at"`
}
type UserSubCourseProgress struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
@ -399,6 +410,16 @@ type UserSubCourseProgress struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type UserSubCourseVideoProgress struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
SubCourseID int64 `json:"sub_course_id"`
VideoID int64 `json:"video_id"`
CompletedAt pgtype.Timestamp `json:"completed_at"`
CreatedAt pgtype.Timestamp `json:"created_at"`
UpdatedAt pgtype.Timestamp `json:"updated_at"`
}
type UserSubscription struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`

View File

@ -373,6 +373,41 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets
return items, nil
}
const GetSubCourseInitialAssessmentSet = `-- name: GetSubCourseInitialAssessmentSet :one
SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order
FROM question_sets
WHERE set_type = 'INITIAL_ASSESSMENT'
AND owner_type = 'SUB_COURSE'
AND owner_id = $1
AND status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 1
`
func (q *Queries) GetSubCourseInitialAssessmentSet(ctx context.Context, ownerID pgtype.Int8) (QuestionSet, error) {
row := q.db.QueryRow(ctx, GetSubCourseInitialAssessmentSet, ownerID)
var i QuestionSet
err := row.Scan(
&i.ID,
&i.Title,
&i.Description,
&i.SetType,
&i.OwnerType,
&i.OwnerID,
&i.BannerImage,
&i.Persona,
&i.TimeLimitMinutes,
&i.PassingScore,
&i.ShuffleQuestions,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.SubCourseVideoID,
&i.DisplayOrder,
)
return i, err
}
const GetUserPersonasByQuestionSetID = `-- name: GetUserPersonasByQuestionSetID :many
SELECT
u.id,

View File

@ -19,10 +19,11 @@ INSERT INTO sub_courses (
thumbnail,
display_order,
level,
sub_level,
is_active
)
VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, COALESCE($7, true))
RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active
VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, $7, COALESCE($8, true))
RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active, sub_level
`
type CreateSubCourseParams struct {
@ -32,7 +33,8 @@ type CreateSubCourseParams struct {
Thumbnail pgtype.Text `json:"thumbnail"`
Column5 interface{} `json:"column_5"`
Level string `json:"level"`
Column7 interface{} `json:"column_7"`
SubLevel string `json:"sub_level"`
Column8 interface{} `json:"column_8"`
}
func (q *Queries) CreateSubCourse(ctx context.Context, arg CreateSubCourseParams) (SubCourse, error) {
@ -43,7 +45,8 @@ func (q *Queries) CreateSubCourse(ctx context.Context, arg CreateSubCourseParams
arg.Thumbnail,
arg.Column5,
arg.Level,
arg.Column7,
arg.SubLevel,
arg.Column8,
)
var i SubCourse
err := row.Scan(
@ -55,6 +58,7 @@ func (q *Queries) CreateSubCourse(ctx context.Context, arg CreateSubCourseParams
&i.DisplayOrder,
&i.Level,
&i.IsActive,
&i.SubLevel,
)
return i, err
}
@ -73,7 +77,7 @@ func (q *Queries) DeactivateSubCourse(ctx context.Context, id int64) error {
const DeleteSubCourse = `-- name: DeleteSubCourse :one
DELETE FROM sub_courses
WHERE id = $1
RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active
RETURNING id, course_id, title, description, thumbnail, display_order, level, is_active, sub_level
`
func (q *Queries) DeleteSubCourse(ctx context.Context, id int64) (SubCourse, error) {
@ -88,12 +92,13 @@ func (q *Queries) DeleteSubCourse(ctx context.Context, id int64) (SubCourse, err
&i.DisplayOrder,
&i.Level,
&i.IsActive,
&i.SubLevel,
)
return i, err
}
const GetSubCourseByID = `-- name: GetSubCourseByID :one
SELECT id, course_id, title, description, thumbnail, display_order, level, is_active
SELECT id, course_id, title, description, thumbnail, display_order, level, is_active, sub_level
FROM sub_courses
WHERE id = $1
`
@ -110,6 +115,7 @@ func (q *Queries) GetSubCourseByID(ctx context.Context, id int64) (SubCourse, er
&i.DisplayOrder,
&i.Level,
&i.IsActive,
&i.SubLevel,
)
return i, err
}
@ -124,6 +130,7 @@ SELECT
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE course_id = $1
@ -139,6 +146,7 @@ type GetSubCoursesByCourseRow struct {
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
}
@ -160,6 +168,7 @@ func (q *Queries) GetSubCoursesByCourse(ctx context.Context, courseID int64) ([]
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.SubLevel,
&i.IsActive,
); err != nil {
return nil, err
@ -181,21 +190,34 @@ SELECT
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE is_active = TRUE
ORDER BY display_order ASC
`
func (q *Queries) ListActiveSubCourses(ctx context.Context) ([]SubCourse, error) {
type ListActiveSubCoursesRow struct {
ID int64 `json:"id"`
CourseID int64 `json:"course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
}
func (q *Queries) ListActiveSubCourses(ctx context.Context) ([]ListActiveSubCoursesRow, error) {
rows, err := q.db.Query(ctx, ListActiveSubCourses)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SubCourse
var items []ListActiveSubCoursesRow
for rows.Next() {
var i SubCourse
var i ListActiveSubCoursesRow
if err := rows.Scan(
&i.ID,
&i.CourseID,
@ -204,6 +226,7 @@ func (q *Queries) ListActiveSubCourses(ctx context.Context) ([]SubCourse, error)
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.SubLevel,
&i.IsActive,
); err != nil {
return nil, err
@ -225,6 +248,7 @@ SELECT
thumbnail,
display_order,
level,
sub_level,
is_active
FROM sub_courses
WHERE course_id = $1
@ -232,15 +256,27 @@ WHERE course_id = $1
ORDER BY display_order ASC, id ASC
`
func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([]SubCourse, error) {
type ListSubCoursesByCourseRow struct {
ID int64 `json:"id"`
CourseID int64 `json:"course_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
}
func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([]ListSubCoursesByCourseRow, error) {
rows, err := q.db.Query(ctx, ListSubCoursesByCourse, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SubCourse
var items []ListSubCoursesByCourseRow
for rows.Next() {
var i SubCourse
var i ListSubCoursesByCourseRow
if err := rows.Scan(
&i.ID,
&i.CourseID,
@ -249,6 +285,7 @@ func (q *Queries) ListSubCoursesByCourse(ctx context.Context, courseID int64) ([
&i.Thumbnail,
&i.DisplayOrder,
&i.Level,
&i.SubLevel,
&i.IsActive,
); err != nil {
return nil, err
@ -288,8 +325,9 @@ SET
thumbnail = COALESCE($3, thumbnail),
display_order = COALESCE($4, display_order),
level = COALESCE($5, level),
is_active = COALESCE($6, is_active)
WHERE id = $7
sub_level = COALESCE($6, sub_level),
is_active = COALESCE($7, is_active)
WHERE id = $8
`
type UpdateSubCourseParams struct {
@ -298,6 +336,7 @@ type UpdateSubCourseParams struct {
Thumbnail pgtype.Text `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
ID int64 `json:"id"`
}
@ -309,6 +348,7 @@ func (q *Queries) UpdateSubCourse(ctx context.Context, arg UpdateSubCourseParams
arg.Thumbnail,
arg.DisplayOrder,
arg.Level,
arg.SubLevel,
arg.IsActive,
arg.ID,
)

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"
)
type SubCourseSubLevel string
const (
SubCourseSubLevelA1 SubCourseSubLevel = "A1"
SubCourseSubLevelA2 SubCourseSubLevel = "A2"
SubCourseSubLevelA3 SubCourseSubLevel = "A3"
SubCourseSubLevelB1 SubCourseSubLevel = "B1"
SubCourseSubLevelB2 SubCourseSubLevel = "B2"
SubCourseSubLevelB3 SubCourseSubLevel = "B3"
SubCourseSubLevelC1 SubCourseSubLevel = "C1"
SubCourseSubLevelC2 SubCourseSubLevel = "C2"
SubCourseSubLevelC3 SubCourseSubLevel = "C3"
)
type ContentStatus string
const (
@ -20,9 +34,10 @@ const (
)
type TreeSubCourse struct {
ID int64
Title string
Level string
ID int64
Title string
Level string
SubLevel string
}
type TreeCourse struct {
@ -56,6 +71,7 @@ type SubCourse struct {
Thumbnail *string
DisplayOrder int32
Level string
SubLevel string
IsActive bool
}
@ -88,6 +104,12 @@ const (
VideoHostProviderVimeo VideoHostProvider = "VIMEO"
)
type VideoAccessBlock struct {
VideoID int64
Title string
DisplayOrder int32
}
// Learning Path types — full nested structure for a course
type LearningPathVideo struct {
ID int64 `json:"id"`
@ -115,6 +137,7 @@ type LearningPathPrerequisite struct {
SubCourseID int64 `json:"sub_course_id"`
Title string `json:"title"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
}
type LearningPathSubCourse struct {
@ -124,6 +147,7 @@ type LearningPathSubCourse struct {
Thumbnail *string `json:"thumbnail,omitempty"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
PrerequisiteCount int64 `json:"prerequisite_count"`
VideoCount int64 `json:"video_count"`
PracticeCount int64 `json:"practice_count"`

View File

@ -29,6 +29,12 @@ const (
QuestionSetTypeSurvey QuestionSetType = "SURVEY"
)
type PracticeAccessBlock struct {
QuestionSetID int64
Title string
DisplayOrder int32
}
type MatchType string
const (

View File

@ -73,6 +73,7 @@ type CourseStore interface {
thumbnail *string,
displayOrder *int32,
level string,
subLevel string,
) (domain.SubCourse, error)
GetSubCourseByID(
ctx context.Context,
@ -97,6 +98,7 @@ type CourseStore interface {
thumbnail *string,
displayOrder *int32,
level *string,
subLevel *string,
isActive *bool,
) error
DeactivateSubCourse(
@ -140,6 +142,16 @@ type CourseStore interface {
ctx context.Context,
subCourseID int64,
) ([]domain.SubCourseVideo, error)
GetFirstIncompletePreviousVideo(
ctx context.Context,
userID int64,
videoID int64,
) (*domain.VideoAccessBlock, error)
MarkVideoCompleted(
ctx context.Context,
userID int64,
videoID int64,
) error
PublishSubCourseVideo(
ctx context.Context,
videoID int64,

View File

@ -37,6 +37,9 @@ type QuestionStore interface {
GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error)
GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error)
GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet, error)
GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error)
GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error)
MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error
UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error
ArchiveQuestionSet(ctx context.Context, id int64) error
DeleteQuestionSet(ctx context.Context, id int64) error

View File

@ -29,9 +29,10 @@ func (s *Store) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, e
if row.SubCourseID.Valid {
subCourse := domain.TreeSubCourse{
ID: row.SubCourseID.Int64,
Title: row.SubCourseTitle.String,
Level: row.SubCourseLevel.String,
ID: row.SubCourseID.Int64,
Title: row.SubCourseTitle.String,
Level: row.SubCourseLevel.String,
SubLevel: row.SubCourseSubLevel.String,
}
course.SubCourses = append(course.SubCourses, subCourse)
}
@ -85,6 +86,7 @@ func (s *Store) GetCourseLearningPath(ctx context.Context, courseID int64) (doma
Thumbnail: ptrString(row.SubCourseThumbnail),
DisplayOrder: row.SubCourseDisplayOrder.Int32,
Level: row.SubCourseLevel.String,
SubLevel: row.SubCourseSubLevel.String,
PrerequisiteCount: row.PrerequisiteCount,
VideoCount: row.VideoCount,
PracticeCount: row.PracticeCount,
@ -109,6 +111,7 @@ func (s *Store) getSubCoursePrerequisitesForPath(ctx context.Context, subCourseI
SubCourseID: row.PrerequisiteSubCourseID,
Title: row.Title,
Level: row.Level,
SubLevel: row.SubLevel,
}
}
return result, nil

View File

@ -2,11 +2,13 @@ package repository
import (
"context"
"errors"
"time"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
@ -632,6 +634,40 @@ func (s *Store) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet
return questionSetToDomain(qs), nil
}
func (s *Store) GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error) {
qs, err := s.queries.GetSubCourseInitialAssessmentSet(ctx, pgtype.Int8{Int64: subCourseID, Valid: true})
if err != nil {
return domain.QuestionSet{}, err
}
return questionSetToDomain(qs), nil
}
func (s *Store) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) {
row, err := s.queries.GetFirstIncompletePreviousPractice(ctx, dbgen.GetFirstIncompletePreviousPracticeParams{
UserID: userID,
QuestionSetID: questionSetID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &domain.PracticeAccessBlock{
QuestionSetID: row.ID,
Title: row.Title,
DisplayOrder: row.DisplayOrder,
}, nil
}
func (s *Store) MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error {
_, err := s.queries.MarkPracticeCompleted(ctx, dbgen.MarkPracticeCompletedParams{
UserID: userID,
QuestionSetID: questionSetID,
})
return err
}
func (s *Store) UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error {
var shuffleQuestions bool
if input.ShuffleQuestions != nil {

View File

@ -4,7 +4,9 @@ import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
@ -167,6 +169,41 @@ func (s *Store) GetPublishedVideosBySubCourse(
return videos, nil
}
func (s *Store) GetFirstIncompletePreviousVideo(
ctx context.Context,
userID int64,
videoID int64,
) (*domain.VideoAccessBlock, error) {
row, err := s.queries.GetFirstIncompletePreviousVideo(ctx, dbgen.GetFirstIncompletePreviousVideoParams{
UserID: userID,
VideoID: videoID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &domain.VideoAccessBlock{
VideoID: row.ID,
Title: row.Title,
DisplayOrder: row.DisplayOrder,
}, nil
}
func (s *Store) MarkVideoCompleted(
ctx context.Context,
userID int64,
videoID int64,
) error {
_, err := s.queries.MarkVideoCompleted(ctx, dbgen.MarkVideoCompletedParams{
UserID: userID,
VideoID: videoID,
})
return err
}
func (s *Store) PublishSubCourseVideo(
ctx context.Context,
videoID int64,

View File

@ -16,6 +16,7 @@ func (s *Store) CreateSubCourse(
thumbnail *string,
displayOrder *int32,
level string,
subLevel string,
) (domain.SubCourse, error) {
var descText, thumbText pgtype.Text
if description != nil {
@ -37,7 +38,8 @@ func (s *Store) CreateSubCourse(
Thumbnail: thumbText,
Column5: dispOrder,
Level: level,
Column7: pgtype.Bool{Bool: true, Valid: true},
SubLevel: subLevel,
Column8: pgtype.Bool{Bool: true, Valid: true},
})
if err != nil {
return domain.SubCourse{}, err
@ -51,6 +53,7 @@ func (s *Store) CreateSubCourse(
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
}, nil
}
@ -72,6 +75,7 @@ func (s *Store) GetSubCourseByID(
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
}, nil
}
@ -103,6 +107,7 @@ func (s *Store) GetSubCoursesByCourse(
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
})
}
@ -129,6 +134,7 @@ func (s *Store) ListSubCoursesByCourse(
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
})
}
@ -154,6 +160,7 @@ func (s *Store) ListActiveSubCourses(
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
})
}
@ -169,9 +176,10 @@ func (s *Store) UpdateSubCourse(
thumbnail *string,
displayOrder *int32,
level *string,
subLevel *string,
isActive *bool,
) error {
var titleVal, descVal, thumbVal, levelVal string
var titleVal, descVal, thumbVal, levelVal, subLevelVal string
var dispOrderVal int32
var isActiveVal bool
@ -190,6 +198,9 @@ func (s *Store) UpdateSubCourse(
if level != nil {
levelVal = *level
}
if subLevel != nil {
subLevelVal = *subLevel
}
if isActive != nil {
isActiveVal = *isActive
}
@ -200,6 +211,7 @@ func (s *Store) UpdateSubCourse(
Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil},
DisplayOrder: dispOrderVal,
Level: levelVal,
SubLevel: subLevelVal,
IsActive: isActiveVal,
ID: id,
})
@ -229,6 +241,7 @@ func (s *Store) DeleteSubCourse(
Thumbnail: ptrString(row.Thumbnail),
DisplayOrder: row.DisplayOrder,
Level: row.Level,
SubLevel: row.SubLevel,
IsActive: row.IsActive,
}, nil
}

View File

@ -245,6 +245,22 @@ func (s *Service) GetPublishedVideosBySubCourse(
return s.courseStore.GetPublishedVideosBySubCourse(ctx, subCourseID)
}
func (s *Service) GetFirstIncompletePreviousVideo(
ctx context.Context,
userID int64,
videoID int64,
) (*domain.VideoAccessBlock, error) {
return s.courseStore.GetFirstIncompletePreviousVideo(ctx, userID, videoID)
}
func (s *Service) MarkVideoCompleted(
ctx context.Context,
userID int64,
videoID int64,
) error {
return s.courseStore.MarkVideoCompleted(ctx, userID, videoID)
}
func (s *Service) PublishSubCourseVideo(
ctx context.Context,
videoID int64,

View File

@ -13,8 +13,9 @@ func (s *Service) CreateSubCourse(
thumbnail *string,
displayOrder *int32,
level string,
subLevel string,
) (domain.SubCourse, error) {
return s.courseStore.CreateSubCourse(ctx, courseID, title, description, thumbnail, displayOrder, level)
return s.courseStore.CreateSubCourse(ctx, courseID, title, description, thumbnail, displayOrder, level, subLevel)
}
func (s *Service) GetSubCourseByID(
@ -52,9 +53,10 @@ func (s *Service) UpdateSubCourse(
thumbnail *string,
displayOrder *int32,
level *string,
subLevel *string,
isActive *bool,
) error {
return s.courseStore.UpdateSubCourse(ctx, id, title, description, thumbnail, displayOrder, level, isActive)
return s.courseStore.UpdateSubCourse(ctx, id, title, description, thumbnail, displayOrder, level, subLevel, isActive)
}
func (s *Service) DeactivateSubCourse(

View File

@ -120,6 +120,18 @@ func (s *Service) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionS
return s.questionStore.GetInitialAssessmentSet(ctx)
}
func (s *Service) GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error) {
return s.questionStore.GetSubCourseInitialAssessmentSet(ctx, subCourseID)
}
func (s *Service) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) {
return s.questionStore.GetFirstIncompletePreviousPractice(ctx, userID, questionSetID)
}
func (s *Service) MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error {
return s.questionStore.MarkPracticeCompleted(ctx, userID, questionSetID)
}
func (s *Service) UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error {
return s.questionStore.UpdateQuestionSet(ctx, id, input)
}

View File

@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
@ -579,6 +580,7 @@ type createSubCourseReq struct {
Thumbnail *string `json:"thumbnail"`
DisplayOrder *int32 `json:"display_order"`
Level string `json:"level" validate:"required"` // BEGINNER, INTERMEDIATE, ADVANCED
SubLevel string `json:"sub_level" validate:"required"` // A1..C3 depending on level
}
type subCourseRes struct {
@ -589,9 +591,29 @@ type subCourseRes struct {
Thumbnail *string `json:"thumbnail"`
DisplayOrder int32 `json:"display_order"`
Level string `json:"level"`
SubLevel string `json:"sub_level"`
IsActive bool `json:"is_active"`
}
func isValidSubLevelForLevel(level, subLevel string) bool {
switch strings.ToUpper(level) {
case string(domain.SubCourseLevelBeginner):
return subLevel == string(domain.SubCourseSubLevelA1) ||
subLevel == string(domain.SubCourseSubLevelA2) ||
subLevel == string(domain.SubCourseSubLevelA3)
case string(domain.SubCourseLevelIntermediate):
return subLevel == string(domain.SubCourseSubLevelB1) ||
subLevel == string(domain.SubCourseSubLevelB2) ||
subLevel == string(domain.SubCourseSubLevelB3)
case string(domain.SubCourseLevelAdvanced):
return subLevel == string(domain.SubCourseSubLevelC1) ||
subLevel == string(domain.SubCourseSubLevelC2) ||
subLevel == string(domain.SubCourseSubLevelC3)
default:
return false
}
}
// CreateSubCourse godoc
// @Summary Create a new sub-course
// @Description Creates a new sub-course under a specific course
@ -612,7 +634,14 @@ func (h *Handler) CreateSubCourse(c *fiber.Ctx) error {
})
}
subCourse, err := h.courseMgmtSvc.CreateSubCourse(c.Context(), req.CourseID, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.Level)
if !isValidSubLevelForLevel(req.Level, req.SubLevel) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub_level for the selected level",
Error: "BEGINNER requires A1/A2/A3, INTERMEDIATE requires B1/B2/B3, ADVANCED requires C1/C2/C3",
})
}
subCourse, err := h.courseMgmtSvc.CreateSubCourse(c.Context(), req.CourseID, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.Level, req.SubLevel)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create sub-course",
@ -624,7 +653,7 @@ func (h *Handler) CreateSubCourse(c *fiber.Ctx) error {
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"title": subCourse.Title, "course_id": subCourse.CourseID, "level": subCourse.Level})
meta, _ := json.Marshal(map[string]interface{}{"title": subCourse.Title, "course_id": subCourse.CourseID, "level": subCourse.Level, "sub_level": subCourse.SubLevel})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseCreated, domain.ResourceSubCourse, &subCourse.ID, "Created sub-course: "+subCourse.Title, meta, &ip, &ua)
go func() {
@ -647,6 +676,7 @@ func (h *Handler) CreateSubCourse(c *fiber.Ctx) error {
Thumbnail: subCourse.Thumbnail,
DisplayOrder: subCourse.DisplayOrder,
Level: subCourse.Level,
SubLevel: subCourse.SubLevel,
IsActive: subCourse.IsActive,
},
})
@ -691,6 +721,7 @@ func (h *Handler) GetSubCourseByID(c *fiber.Ctx) error {
Thumbnail: subCourse.Thumbnail,
DisplayOrder: subCourse.DisplayOrder,
Level: subCourse.Level,
SubLevel: subCourse.SubLevel,
IsActive: subCourse.IsActive,
},
})
@ -739,6 +770,7 @@ func (h *Handler) GetSubCoursesByCourse(c *fiber.Ctx) error {
Thumbnail: sc.Thumbnail,
DisplayOrder: sc.DisplayOrder,
Level: sc.Level,
SubLevel: sc.SubLevel,
IsActive: sc.IsActive,
})
}
@ -790,6 +822,7 @@ func (h *Handler) ListSubCoursesByCourse(c *fiber.Ctx) error {
Thumbnail: sc.Thumbnail,
DisplayOrder: sc.DisplayOrder,
Level: sc.Level,
SubLevel: sc.SubLevel,
IsActive: sc.IsActive,
})
}
@ -827,6 +860,7 @@ func (h *Handler) ListActiveSubCourses(c *fiber.Ctx) error {
Thumbnail: sc.Thumbnail,
DisplayOrder: sc.DisplayOrder,
Level: sc.Level,
SubLevel: sc.SubLevel,
IsActive: sc.IsActive,
})
}
@ -843,6 +877,7 @@ type updateSubCourseReq struct {
Thumbnail *string `json:"thumbnail"`
DisplayOrder *int32 `json:"display_order"`
Level *string `json:"level"`
SubLevel *string `json:"sub_level"`
IsActive *bool `json:"is_active"`
}
@ -876,7 +911,33 @@ func (h *Handler) UpdateSubCourse(c *fiber.Ctx) error {
})
}
err = h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.Level, req.IsActive)
if req.Level != nil || req.SubLevel != nil {
existing, getErr := h.courseMgmtSvc.GetSubCourseByID(c.Context(), id)
if getErr != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Sub-course not found",
Error: getErr.Error(),
})
}
level := existing.Level
subLevel := existing.SubLevel
if req.Level != nil {
level = *req.Level
}
if req.SubLevel != nil {
subLevel = *req.SubLevel
}
if !isValidSubLevelForLevel(level, subLevel) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub_level for the selected level",
Error: "BEGINNER requires A1/A2/A3, INTERMEDIATE requires B1/B2/B3, ADVANCED requires C1/C2/C3",
})
}
}
err = h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.Level, req.SubLevel, req.IsActive)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update sub-course",
@ -888,7 +949,7 @@ func (h *Handler) UpdateSubCourse(c *fiber.Ctx) error {
actorRole := string(c.Locals("role").(domain.Role))
ip := c.IP()
ua := c.Get("User-Agent")
meta, _ := json.Marshal(map[string]interface{}{"id": id, "title": req.Title, "description": req.Description, "level": req.Level, "is_active": req.IsActive})
meta, _ := json.Marshal(map[string]interface{}{"id": id, "title": req.Title, "description": req.Description, "level": req.Level, "sub_level": req.SubLevel, "is_active": req.IsActive})
go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseUpdated, domain.ResourceSubCourse, &id, fmt.Sprintf("Updated sub-course ID: %d", id), meta, &ip, &ua)
return c.JSON(domain.Response{
@ -1136,6 +1197,31 @@ func (h *Handler) GetSubCourseVideoByID(c *fiber.Ctx) error {
})
}
role := c.Locals("role").(domain.Role)
if role == domain.RoleStudent {
userID := c.Locals("user_id").(int64)
if video.Status != string(domain.ContentStatusPublished) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Video not found",
})
}
blockedBy, err := h.courseMgmtSvc.GetFirstIncompletePreviousVideo(c.Context(), userID, video.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to validate video access",
Error: err.Error(),
})
}
if blockedBy != nil {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "You must complete previous videos first",
Error: fmt.Sprintf("Complete video '%s' (display_order=%d) before accessing this one", blockedBy.Title, blockedBy.DisplayOrder),
})
}
}
var publishDate *string
if video.PublishDate != nil {
pd := video.PublishDate.String()
@ -1163,6 +1249,74 @@ func (h *Handler) GetSubCourseVideoByID(c *fiber.Ctx) error {
})
}
// CompleteSubCourseVideo godoc
// @Summary Mark sub-course video as completed
// @Description Marks the given video as completed for the authenticated learner
// @Tags progression
// @Produce json
// @Param id path int true "Video ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/progress/videos/{id}/complete [post]
func (h *Handler) CompleteSubCourseVideo(c *fiber.Ctx) error {
userID := c.Locals("user_id").(int64)
role := c.Locals("role").(domain.Role)
if role != domain.RoleStudent {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Only learners can complete videos",
})
}
videoID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid video ID",
Error: err.Error(),
})
}
video, err := h.courseMgmtSvc.GetSubCourseVideoByID(c.Context(), videoID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Video not found",
Error: err.Error(),
})
}
if video.Status != string(domain.ContentStatusPublished) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Video not found",
})
}
blockedBy, err := h.courseMgmtSvc.GetFirstIncompletePreviousVideo(c.Context(), userID, videoID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to validate video completion",
Error: err.Error(),
})
}
if blockedBy != nil {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "You must complete previous videos first",
Error: fmt.Sprintf("Complete video '%s' (display_order=%d) before completing this one", blockedBy.Title, blockedBy.DisplayOrder),
})
}
if err := h.courseMgmtSvc.MarkVideoCompleted(c.Context(), userID, videoID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to complete video",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Video completed",
})
}
type getVideosBySubCourseRes struct {
Videos []subCourseVideoRes `json:"videos"`
TotalCount int64 `json:"total_count"`
@ -2024,7 +2178,7 @@ func (h *Handler) UploadSubCourseThumbnail(c *fiber.Ctx) error {
return err
}
if err := h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, nil, nil, &publicPath, nil, nil, nil); err != nil {
if err := h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, nil, nil, &publicPath, nil, nil, nil, nil); err != nil {
_ = os.Remove(filepath.Join(".", publicPath))
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update sub-course thumbnail",

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
)
@ -549,6 +550,40 @@ type listQuestionSetsRes struct {
TotalCount int64 `json:"total_count"`
}
func isSubCoursePractice(set domain.QuestionSet) bool {
return strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) &&
set.OwnerType != nil &&
strings.EqualFold(*set.OwnerType, "SUB_COURSE")
}
func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error {
role := c.Locals("role").(domain.Role)
if role != domain.RoleStudent || !isSubCoursePractice(set) {
return nil
}
if !strings.EqualFold(set.Status, string(domain.ContentStatusPublished)) {
return fiber.NewError(fiber.StatusNotFound, "Practice not found")
}
userID := c.Locals("user_id").(int64)
blockedBy, err := h.questionsSvc.GetFirstIncompletePreviousPractice(c.Context(), userID, set.ID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate practice access")
}
if blockedBy != nil {
return fiber.NewError(
fiber.StatusForbidden,
fmt.Sprintf(
"Complete practice '%s' (display_order=%d) before accessing this one",
blockedBy.Title,
blockedBy.DisplayOrder,
),
)
}
return nil
}
// CreateQuestionSet godoc
// @Summary Create a new question set
// @Description Creates a new question set (practice, assessment, quiz, exam, or survey)
@ -569,6 +604,15 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error {
})
}
if strings.EqualFold(req.SetType, string(domain.QuestionSetTypeInitialAssessment)) {
if req.OwnerType == nil || !strings.EqualFold(*req.OwnerType, "SUB_COURSE") || req.OwnerID == nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid initial assessment ownership",
Error: "INITIAL_ASSESSMENT question sets must include owner_type=SUB_COURSE and owner_id",
})
}
}
input := domain.CreateQuestionSetInput{
Title: req.Title,
Description: req.Description,
@ -620,6 +664,59 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error {
})
}
// GetSubCourseEntryAssessmentSet godoc
// @Summary Get entry assessment set for a sub-course
// @Description Returns the published INITIAL_ASSESSMENT question set for the given sub-course
// @Tags question-sets
// @Produce json
// @Param subCourseId path int true "Sub-course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/question-sets/sub-courses/{subCourseId}/entry-assessment [get]
func (h *Handler) GetSubCourseEntryAssessmentSet(c *fiber.Ctx) error {
subCourseIDStr := c.Params("subCourseId")
subCourseID, err := strconv.ParseInt(subCourseIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid sub-course ID",
Error: err.Error(),
})
}
set, err := h.questionsSvc.GetSubCourseInitialAssessmentSet(c.Context(), subCourseID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Entry assessment set not found for sub-course",
Error: err.Error(),
})
}
count, _ := h.questionsSvc.CountQuestionsInSet(c.Context(), set.ID)
return c.JSON(domain.Response{
Message: "Entry assessment set retrieved successfully",
Data: questionSetRes{
ID: set.ID,
Title: set.Title,
Description: set.Description,
SetType: set.SetType,
OwnerType: set.OwnerType,
OwnerID: set.OwnerID,
BannerImage: set.BannerImage,
Persona: set.Persona,
TimeLimitMinutes: set.TimeLimitMinutes,
PassingScore: set.PassingScore,
ShuffleQuestions: set.ShuffleQuestions,
Status: set.Status,
SubCourseVideoID: set.SubCourseVideoID,
CreatedAt: set.CreatedAt.String(),
QuestionCount: &count,
},
})
}
// GetQuestionSetByID godoc
// @Summary Get question set by ID
// @Description Returns a question set with question count
@ -648,6 +745,17 @@ func (h *Handler) GetQuestionSetByID(c *fiber.Ctx) error {
})
}
if err := h.enforcePracticeSequenceForStudent(c, set); err != nil {
status := fiber.StatusForbidden
if ferr, ok := err.(*fiber.Error); ok {
status = ferr.Code
}
return c.Status(status).JSON(domain.ErrorResponse{
Message: "Practice is locked",
Error: err.Error(),
})
}
count, _ := h.questionsSvc.CountQuestionsInSet(c.Context(), id)
return c.JSON(domain.Response{
@ -1007,6 +1115,25 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
})
}
set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), setID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Question set not found",
Error: err.Error(),
})
}
if err := h.enforcePracticeSequenceForStudent(c, set); err != nil {
status := fiber.StatusForbidden
if ferr, ok := err.(*fiber.Error); ok {
status = ferr.Code
}
return c.Status(status).JSON(domain.ErrorResponse{
Message: "Practice is locked",
Error: err.Error(),
})
}
items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), setID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
@ -1039,6 +1166,67 @@ func (h *Handler) GetQuestionsInSet(c *fiber.Ctx) error {
})
}
// CompletePractice godoc
// @Summary Mark practice as completed
// @Description Marks a practice question set as completed for the authenticated learner
// @Tags progression
// @Produce json
// @Param id path int true "Practice Question Set ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 403 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/progress/practices/{id}/complete [post]
func (h *Handler) CompletePractice(c *fiber.Ctx) error {
role := c.Locals("role").(domain.Role)
if role != domain.RoleStudent {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "Only learners can complete practices",
})
}
userID := c.Locals("user_id").(int64)
setID, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid practice ID",
Error: err.Error(),
})
}
set, err := h.questionsSvc.GetQuestionSetByID(c.Context(), setID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: err.Error(),
})
}
if !isSubCoursePractice(set) || !strings.EqualFold(set.Status, string(domain.ContentStatusPublished)) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
})
}
if err := h.enforcePracticeSequenceForStudent(c, set); err != nil {
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
Message: "You must complete previous practices first",
Error: err.Error(),
})
}
if err := h.questionsSvc.MarkPracticeCompleted(c.Context(), userID, set.ID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to complete practice",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Practice completed",
})
}
// RemoveQuestionFromSet godoc
// @Summary Remove question from set
// @Description Unlinks a question from a question set

View File

@ -134,6 +134,7 @@ func (a *App) initAppRoutes() {
groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet)
groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)
groupV1.Get("/question-sets/by-owner", a.authMiddleware, a.RequirePermission("question_sets.list_by_owner"), h.GetQuestionSetsByOwner)
groupV1.Get("/question-sets/sub-courses/:subCourseId/entry-assessment", a.authMiddleware, a.RequirePermission("question_sets.list_by_owner"), h.GetSubCourseEntryAssessmentSet)
groupV1.Get("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetQuestionSetByID)
groupV1.Put("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateQuestionSet)
groupV1.Delete("/question-sets/:id", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteQuestionSet)
@ -326,6 +327,8 @@ func (a *App) initAppRoutes() {
groupV1.Post("/progress/sub-courses/:id/start", a.authMiddleware, a.RequirePermission("progress.start"), h.StartSubCourse)
groupV1.Put("/progress/sub-courses/:id", a.authMiddleware, a.RequirePermission("progress.update"), h.UpdateSubCourseProgress)
groupV1.Post("/progress/sub-courses/:id/complete", a.authMiddleware, a.RequirePermission("progress.complete"), h.CompleteSubCourse)
groupV1.Post("/progress/videos/:id/complete", a.authMiddleware, a.RequirePermission("progress.update"), h.CompleteSubCourseVideo)
groupV1.Post("/progress/practices/:id/complete", a.authMiddleware, a.RequirePermission("progress.update"), h.CompletePractice)
groupV1.Get("/progress/sub-courses/:id/access", a.authMiddleware, a.RequirePermission("progress.check_access"), h.CheckSubCourseAccess)
groupV1.Get("/progress/courses/:courseId", a.authMiddleware, a.RequirePermission("progress.get_course"), h.GetUserCourseProgress)