diff --git a/db/migrations/000030_unified_hierarchy.down.sql b/db/migrations/000030_unified_hierarchy.down.sql new file mode 100644 index 0000000..831799d --- /dev/null +++ b/db/migrations/000030_unified_hierarchy.down.sql @@ -0,0 +1,25 @@ +UPDATE question_sets qs +SET owner_type = 'SUB_COURSE', + owner_id = sm.legacy_sub_course_id +FROM sub_modules sm +WHERE qs.owner_type = 'SUB_MODULE' + AND qs.owner_id = sm.id + AND qs.set_type = 'PRACTICE' + AND sm.legacy_sub_course_id IS NOT NULL; + +DROP TABLE IF EXISTS sub_module_practices CASCADE; +DROP TABLE IF EXISTS sub_module_videos CASCADE; +DROP TABLE IF EXISTS sub_modules CASCADE; +DROP TABLE IF EXISTS modules CASCADE; +DROP TABLE IF EXISTS levels CASCADE; + +ALTER TABLE courses DROP COLUMN IF EXISTS sub_category_id; +DROP TABLE IF EXISTS course_sub_categories CASCADE; + +-- Best-effort rollback to old expectation. +UPDATE user_practice_progress +SET sub_course_id = 1 +WHERE sub_course_id IS NULL; +ALTER TABLE user_practice_progress +ALTER COLUMN sub_course_id SET NOT NULL; + diff --git a/db/migrations/000030_unified_hierarchy.up.sql b/db/migrations/000030_unified_hierarchy.up.sql new file mode 100644 index 0000000..892a593 --- /dev/null +++ b/db/migrations/000030_unified_hierarchy.up.sql @@ -0,0 +1,228 @@ +-- Unified hierarchy +-- Course Category -> Course Sub-category -> Course -> Level -> Module -> Sub-Module +-- -> Sub-Module Videos +-- -> Sub-Module Practices (question sets) + +CREATE TABLE IF NOT EXISTS course_sub_categories ( + id BIGSERIAL PRIMARY KEY, + category_id BIGINT NOT NULL REFERENCES course_categories(id) ON DELETE CASCADE, + name VARCHAR(150) NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + display_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(category_id, name) +); + +ALTER TABLE courses +ADD COLUMN IF NOT EXISTS sub_category_id BIGINT REFERENCES course_sub_categories(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_courses_sub_category_id ON courses(sub_category_id); + +CREATE TABLE IF NOT EXISTS levels ( + id BIGSERIAL PRIMARY KEY, + course_id BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + cefr_level VARCHAR(2) NOT NULL, + display_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(course_id, cefr_level), + CHECK (cefr_level IN ('A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3')) +); + +CREATE INDEX IF NOT EXISTS idx_levels_course_id ON levels(course_id); + +CREATE TABLE IF NOT EXISTS modules ( + id BIGSERIAL PRIMARY KEY, + level_id BIGINT NOT NULL REFERENCES levels(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + display_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_modules_level_id ON modules(level_id); + +CREATE TABLE IF NOT EXISTS sub_modules ( + id BIGSERIAL PRIMARY KEY, + module_id BIGINT NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + display_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + legacy_sub_course_id BIGINT UNIQUE +); + +CREATE INDEX IF NOT EXISTS idx_sub_modules_module_id ON sub_modules(module_id); + +CREATE TABLE IF NOT EXISTS sub_module_videos ( + id BIGSERIAL PRIMARY KEY, + sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + video_url TEXT NOT NULL, + duration INT, + resolution VARCHAR(20), + is_published BOOLEAN NOT NULL DEFAULT FALSE, + publish_date TIMESTAMPTZ, + visibility VARCHAR(50), + instructor_id VARCHAR(100), + thumbnail TEXT, + display_order INT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + vimeo_id TEXT, + vimeo_embed_url TEXT, + vimeo_player_html TEXT, + vimeo_status VARCHAR(50), + video_host_provider VARCHAR(20), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_sub_module_videos_sub_module_id ON sub_module_videos(sub_module_id); + +CREATE TABLE IF NOT EXISTS sub_module_practices ( + id BIGSERIAL PRIMARY KEY, + sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE, + question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE, + intro_video_url TEXT, + display_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(question_set_id) +); + +CREATE INDEX IF NOT EXISTS idx_sub_module_practices_sub_module_id ON sub_module_practices(sub_module_id); + +-- Practice progress now supports sub-module owned practices where no legacy sub_course exists. +ALTER TABLE user_practice_progress +ALTER COLUMN sub_course_id DROP NOT NULL; + +-- Backfill from existing structure +INSERT INTO course_sub_categories (category_id, name, description, display_order, is_active) +SELECT cc.id, c.title || ' Group', 'Auto-generated from existing course structure', 0, TRUE +FROM courses c +JOIN course_categories cc ON cc.id = c.category_id +LEFT JOIN course_sub_categories csc + ON csc.category_id = cc.id AND csc.name = c.title || ' Group' +WHERE csc.id IS NULL; + +UPDATE courses c +SET sub_category_id = csc.id +FROM course_sub_categories csc +WHERE csc.category_id = c.category_id + AND csc.name = c.title || ' Group' + AND c.sub_category_id IS NULL; + +INSERT INTO levels (course_id, cefr_level, display_order, is_active) +SELECT + sc.course_id, + sc.sub_level, + MIN(sc.display_order), + BOOL_AND(sc.is_active) +FROM sub_courses sc +WHERE sc.sub_level IN ('A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3') +GROUP BY sc.course_id, sc.sub_level +ON CONFLICT (course_id, cefr_level) DO NOTHING; + +INSERT INTO modules (level_id, title, description, display_order, is_active) +SELECT + l.id, + l.cefr_level || ' Module 1', + 'Auto-generated default module for ' || l.cefr_level, + 1, + l.is_active +FROM levels l +LEFT JOIN modules m ON m.level_id = l.id AND m.display_order = 1 +WHERE m.id IS NULL; + +INSERT INTO sub_modules (module_id, title, description, display_order, is_active, legacy_sub_course_id) +SELECT + m.id, + sc.title, + sc.description, + sc.display_order, + sc.is_active, + sc.id +FROM sub_courses sc +JOIN levels l + ON l.course_id = sc.course_id + AND l.cefr_level = sc.sub_level +JOIN modules m + ON m.level_id = l.id + AND m.display_order = 1 +LEFT JOIN sub_modules sm ON sm.legacy_sub_course_id = sc.id +WHERE sm.id IS NULL; + +INSERT INTO sub_module_videos ( + sub_module_id, + title, + description, + video_url, + duration, + resolution, + is_published, + publish_date, + visibility, + instructor_id, + thumbnail, + display_order, + status, + vimeo_id, + vimeo_embed_url, + vimeo_player_html, + vimeo_status, + video_host_provider +) +SELECT + sm.id, + scv.title, + scv.description, + scv.video_url, + scv.duration, + scv.resolution, + scv.is_published, + scv.publish_date, + scv.visibility, + scv.instructor_id, + scv.thumbnail, + scv.display_order, + scv.status, + scv.vimeo_id, + scv.vimeo_embed_url, + scv.vimeo_player_html, + scv.vimeo_status, + scv.video_host_provider +FROM sub_course_videos scv +JOIN sub_modules sm ON sm.legacy_sub_course_id = scv.sub_course_id +WHERE NOT EXISTS ( + SELECT 1 + FROM sub_module_videos smv + WHERE smv.sub_module_id = sm.id + AND smv.title = scv.title + AND COALESCE(smv.video_url, '') = COALESCE(scv.video_url, '') +); + +UPDATE question_sets qs +SET owner_type = 'SUB_MODULE', + owner_id = sm.id +FROM sub_modules sm +WHERE qs.owner_type = 'SUB_COURSE' + AND qs.owner_id = sm.legacy_sub_course_id + AND qs.set_type = 'PRACTICE'; + +INSERT INTO sub_module_practices (sub_module_id, question_set_id, intro_video_url, display_order, is_active) +SELECT + sm.id, + qs.id, + qs.intro_video_url, + COALESCE(qs.display_order, 0), + (qs.status != 'ARCHIVED') +FROM question_sets qs +JOIN sub_modules sm + ON qs.owner_type = 'SUB_MODULE' + AND qs.owner_id = sm.id +WHERE qs.set_type = 'PRACTICE' +ON CONFLICT (question_set_id) DO NOTHING; + diff --git a/db/migrations/000031_rename_sub_module_practices_to_lessons.down.sql b/db/migrations/000031_rename_sub_module_practices_to_lessons.down.sql new file mode 100644 index 0000000..d5538d5 --- /dev/null +++ b/db/migrations/000031_rename_sub_module_practices_to_lessons.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE IF EXISTS sub_module_lessons +RENAME TO sub_module_practices; + +ALTER INDEX IF EXISTS idx_sub_module_lessons_sub_module_id +RENAME TO idx_sub_module_practices_sub_module_id; + diff --git a/db/migrations/000031_rename_sub_module_practices_to_lessons.up.sql b/db/migrations/000031_rename_sub_module_practices_to_lessons.up.sql new file mode 100644 index 0000000..618c4c9 --- /dev/null +++ b/db/migrations/000031_rename_sub_module_practices_to_lessons.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE IF EXISTS sub_module_practices +RENAME TO sub_module_lessons; + +ALTER INDEX IF EXISTS idx_sub_module_practices_sub_module_id +RENAME TO idx_sub_module_lessons_sub_module_id; + diff --git a/db/migrations/000032_add_sub_module_practices.down.sql b/db/migrations/000032_add_sub_module_practices.down.sql new file mode 100644 index 0000000..1807b50 --- /dev/null +++ b/db/migrations/000032_add_sub_module_practices.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_sub_module_practices_sub_module_id; + +DROP TABLE IF EXISTS sub_module_practices; + diff --git a/db/migrations/000032_add_sub_module_practices.up.sql b/db/migrations/000032_add_sub_module_practices.up.sql new file mode 100644 index 0000000..48501b9 --- /dev/null +++ b/db/migrations/000032_add_sub_module_practices.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS sub_module_practices ( + id BIGSERIAL PRIMARY KEY, + sub_module_id BIGINT NOT NULL REFERENCES sub_modules(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + thumbnail TEXT, + intro_video_url TEXT, + question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE, + display_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(question_set_id) +); + +CREATE INDEX IF NOT EXISTS idx_sub_module_practices_sub_module_id +ON sub_module_practices(sub_module_id); + diff --git a/db/query/hierarchy.sql b/db/query/hierarchy.sql new file mode 100644 index 0000000..c8f4034 --- /dev/null +++ b/db/query/hierarchy.sql @@ -0,0 +1,195 @@ +-- name: GetCoursesWithHierarchy :many +SELECT + cc.id AS category_id, + cc.name AS category_name, + csc.id AS sub_category_id, + csc.name AS sub_category_name, + c.id AS course_id, + c.title AS course_title +FROM course_categories cc +LEFT JOIN course_sub_categories csc ON csc.category_id = cc.id AND csc.is_active = TRUE +LEFT JOIN courses c ON c.sub_category_id = csc.id AND c.is_active = TRUE +WHERE cc.is_active = TRUE +ORDER BY cc.id, csc.display_order, csc.id, c.id; + +-- name: GetLevelsByCourseID :many +SELECT * +FROM levels +WHERE course_id = $1 + AND is_active = TRUE +ORDER BY display_order ASC, id ASC; + +-- name: GetModulesByLevelID :many +SELECT * +FROM modules +WHERE level_id = $1 + AND is_active = TRUE +ORDER BY display_order ASC, id ASC; + +-- name: GetSubModulesByModuleID :many +SELECT * +FROM sub_modules +WHERE module_id = $1 + AND is_active = TRUE +ORDER BY display_order ASC, id ASC; + +-- name: GetSubModuleVideos :many +SELECT * +FROM sub_module_videos +WHERE sub_module_id = $1 + AND status != 'ARCHIVED' +ORDER BY display_order ASC, id ASC; + +-- name: GetSubModuleLessons :many +SELECT + smp.id, + smp.sub_module_id, + smp.question_set_id, + smp.intro_video_url, + smp.display_order, + smp.is_active, + qs.title, + qs.description, + qs.status, + qs.set_type, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM sub_module_lessons smp +JOIN question_sets qs ON qs.id = smp.question_set_id +WHERE smp.sub_module_id = $1 + AND smp.is_active = TRUE +ORDER BY smp.display_order ASC, smp.id ASC; + +-- name: GetSubModulePractices :many +SELECT + smp.id, + smp.sub_module_id, + smp.title, + smp.description, + smp.thumbnail, + smp.intro_video_url, + smp.question_set_id, + smp.display_order, + smp.is_active, + qs.status, + qs.set_type, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM sub_module_practices smp +JOIN question_sets qs ON qs.id = smp.question_set_id +WHERE smp.sub_module_id = $1 + AND smp.is_active = TRUE +ORDER BY smp.display_order ASC, smp.id ASC; + +-- name: GetFullHierarchyByCourseID :many +SELECT + c.id AS course_id, + c.title AS course_title, + l.id AS level_id, + l.cefr_level, + m.id AS module_id, + m.title AS module_title, + sm.id AS sub_module_id, + sm.title AS sub_module_title +FROM courses c +LEFT JOIN levels l ON l.course_id = c.id AND l.is_active = TRUE +LEFT JOIN modules m ON m.level_id = l.id AND m.is_active = TRUE +LEFT JOIN sub_modules sm ON sm.module_id = m.id AND sm.is_active = TRUE +WHERE c.id = $1 +ORDER BY l.display_order, l.id, m.display_order, m.id, sm.display_order, sm.id; + +-- name: CreateCourseSubCategory :one +INSERT INTO course_sub_categories ( + category_id, + name, + description, + display_order, + is_active +) +VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE)) +RETURNING *; + +-- name: CreateLevel :one +INSERT INTO levels ( + course_id, + cefr_level, + display_order, + is_active +) +VALUES ($1, $2, COALESCE($3, 0), COALESCE($4, TRUE)) +RETURNING *; + +-- name: CreateModule :one +INSERT INTO modules ( + level_id, + title, + description, + display_order, + is_active +) +VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE)) +RETURNING *; + +-- name: CreateSubModule :one +INSERT INTO sub_modules ( + module_id, + title, + description, + display_order, + is_active +) +VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE)) +RETURNING *; + +-- name: CreateSubModuleVideo :one +INSERT INTO sub_module_videos ( + sub_module_id, + title, + description, + video_url, + duration, + resolution, + is_published, + publish_date, + visibility, + instructor_id, + thumbnail, + display_order, + status, + vimeo_id, + vimeo_embed_url, + vimeo_player_html, + vimeo_status, + video_host_provider +) +VALUES ( + $1, $2, $3, $4, $5, $6, + COALESCE($7, FALSE), $8, $9, $10, $11, + COALESCE($12, 0), COALESCE($13, 'DRAFT'), + $14, $15, $16, $17, COALESCE($18, 'DIRECT') +) +RETURNING *; + +-- name: AttachQuestionSetLessonToSubModule :one +INSERT INTO sub_module_lessons ( + sub_module_id, + question_set_id, + intro_video_url, + display_order, + is_active +) +VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE)) +RETURNING *; + +-- name: CreateSubModulePractice :one +INSERT INTO sub_module_practices ( + sub_module_id, + title, + description, + thumbnail, + intro_video_url, + question_set_id, + display_order, + is_active +) +VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE)) +RETURNING *; + diff --git a/db/query/learning_tree.sql b/db/query/learning_tree.sql deleted file mode 100644 index eddcc5f..0000000 --- a/db/query/learning_tree.sql +++ /dev/null @@ -1,59 +0,0 @@ --- name: GetFullLearningTree :many -SELECT - c.id AS course_id, - c.title AS course_title, - sc.id AS sub_course_id, - sc.title AS sub_course_title, - 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 -ORDER BY c.id, sc.display_order, sc.id; - --- name: GetCourseLearningPath :many -SELECT - c.id AS course_id, - c.title AS course_title, - c.description AS course_description, - c.thumbnail AS course_thumbnail, - c.intro_video_url AS course_intro_video_url, - cc.id AS category_id, - cc.name AS category_name, - sc.id AS sub_course_id, - sc.title AS sub_course_title, - sc.description AS sub_course_description, - 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 -FROM courses c -JOIN course_categories cc ON cc.id = c.category_id -LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true -WHERE c.id = $1 -ORDER BY sc.display_order, sc.id; - --- name: GetSubCourseVideosForLearningPath :many -SELECT id, title, description, video_url, duration, resolution, display_order, - vimeo_id, vimeo_embed_url, video_host_provider -FROM sub_course_videos -WHERE sub_course_id = $1 AND status = 'PUBLISHED' -ORDER BY display_order, id; - --- name: GetSubCoursePracticesForLearningPath :many -SELECT id, title, description, persona, status, intro_video_url, - (SELECT COUNT(*) FROM question_set_items WHERE set_id = qs.id) AS question_count -FROM question_sets qs -WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1 - AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' -ORDER BY qs.display_order ASC, qs.created_at; - --- name: GetSubCoursePrerequisitesForLearningPath :many -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 -ORDER BY sc.display_order; diff --git a/db/query/practice_progress.sql b/db/query/practice_progress.sql new file mode 100644 index 0000000..73cecf4 --- /dev/null +++ b/db/query/practice_progress.sql @@ -0,0 +1,55 @@ +-- name: GetFirstIncompletePreviousPractice :one +WITH target AS ( + SELECT id, owner_type, owner_id, COALESCE(display_order, 0) AS display_order + FROM question_sets + WHERE id = @question_set_id::BIGINT + AND set_type = 'PRACTICE' + AND status = 'PUBLISHED' +), +candidates AS ( + SELECT qs.id, qs.title, COALESCE(qs.display_order, 0) AS display_order + FROM question_sets qs + JOIN target t + ON qs.owner_type = t.owner_type + AND qs.owner_id = t.owner_id + WHERE qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND COALESCE(qs.display_order, 0) < t.display_order +) +SELECT c.id, c.title, c.display_order +FROM candidates c +LEFT JOIN user_practice_progress upp + ON upp.question_set_id = c.id + AND upp.user_id = @user_id::BIGINT + AND upp.completed_at IS NOT NULL +WHERE upp.id IS NULL +ORDER BY c.display_order ASC, c.id ASC +LIMIT 1; + +-- name: MarkPracticeCompleted :execrows +INSERT INTO user_practice_progress ( + user_id, + sub_course_id, + question_set_id, + completed_at, + updated_at +) +SELECT + @user_id::BIGINT, + CASE + WHEN qs.owner_type = 'SUB_COURSE' THEN qs.owner_id + WHEN qs.owner_type = 'SUB_MODULE' THEN sm.legacy_sub_course_id + ELSE NULL + END, + qs.id, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM question_sets qs +LEFT JOIN sub_modules sm + ON qs.owner_type = 'SUB_MODULE' + AND qs.owner_id = sm.id +WHERE qs.id = @question_set_id::BIGINT +ON CONFLICT (user_id, question_set_id) DO UPDATE +SET completed_at = EXCLUDED.completed_at, + updated_at = EXCLUDED.updated_at; + diff --git a/db/query/sub_course_prerequisites.sql b/db/query/sub_course_prerequisites.sql deleted file mode 100644 index 4c1fecd..0000000 --- a/db/query/sub_course_prerequisites.sql +++ /dev/null @@ -1,50 +0,0 @@ --- name: AddSubCoursePrerequisite :one -INSERT INTO sub_course_prerequisites (sub_course_id, prerequisite_sub_course_id) -VALUES ($1, $2) -RETURNING *; - --- name: RemoveSubCoursePrerequisite :exec -DELETE FROM sub_course_prerequisites -WHERE sub_course_id = $1 AND prerequisite_sub_course_id = $2; - --- name: GetSubCoursePrerequisites :many -SELECT - p.id, - p.sub_course_id, - p.prerequisite_sub_course_id, - p.created_at, - sc.title AS prerequisite_title, - sc.level AS prerequisite_level, - sc.display_order AS prerequisite_display_order -FROM sub_course_prerequisites p -JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id -WHERE p.sub_course_id = $1 -ORDER BY sc.display_order; - --- name: GetSubCourseDependents :many -SELECT - p.id, - p.sub_course_id, - p.prerequisite_sub_course_id, - p.created_at, - sc.title AS dependent_title, - sc.level AS dependent_level -FROM sub_course_prerequisites p -JOIN sub_courses sc ON sc.id = p.sub_course_id -WHERE p.prerequisite_sub_course_id = $1 -ORDER BY sc.display_order; - --- name: CountUnmetPrerequisites :one -SELECT COUNT(*)::bigint AS unmet_count -FROM sub_course_prerequisites p -WHERE p.sub_course_id = $1 - AND p.prerequisite_sub_course_id NOT IN ( - SELECT usp.sub_course_id - FROM user_sub_course_progress usp - WHERE usp.user_id = $2 - AND usp.status = 'COMPLETED' - ); - --- name: DeleteAllPrerequisitesForSubCourse :exec -DELETE FROM sub_course_prerequisites -WHERE sub_course_id = $1; diff --git a/db/query/sub_course_videos.sql b/db/query/sub_course_videos.sql deleted file mode 100644 index a44881f..0000000 --- a/db/query/sub_course_videos.sql +++ /dev/null @@ -1,122 +0,0 @@ --- name: CreateSubCourseVideo :one -INSERT INTO sub_course_videos ( - sub_course_id, - title, - description, - video_url, - duration, - resolution, - instructor_id, - thumbnail, - visibility, - display_order, - status, - vimeo_id, - vimeo_embed_url, - vimeo_player_html, - vimeo_status, - video_host_provider -) -VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, - COALESCE($10, 0), - COALESCE($11, 'DRAFT'), - $12, $13, $14, - COALESCE($15, 'pending'), - COALESCE($16, 'DIRECT') -) -RETURNING *; - --- name: GetSubCourseVideoByID :one -SELECT * -FROM sub_course_videos -WHERE id = $1; - --- name: GetVideosBySubCourse :many -SELECT - COUNT(*) OVER () AS total_count, - id, - sub_course_id, - title, - description, - video_url, - duration, - resolution, - is_published, - publish_date, - visibility, - instructor_id, - thumbnail, - display_order, - status, - vimeo_id, - vimeo_embed_url, - vimeo_player_html, - vimeo_status, - video_host_provider -FROM sub_course_videos -WHERE sub_course_id = $1 - AND status != 'ARCHIVED' -ORDER BY display_order ASC, id ASC; - --- name: GetPublishedVideosBySubCourse :many -SELECT * -FROM sub_course_videos -WHERE sub_course_id = $1 - AND status = 'PUBLISHED' -ORDER BY display_order ASC, publish_date ASC; - --- name: PublishSubCourseVideo :exec -UPDATE sub_course_videos -SET - is_published = true, - publish_date = CURRENT_TIMESTAMP, - status = 'PUBLISHED' -WHERE id = $1; - --- name: UpdateSubCourseVideo :exec -UPDATE sub_course_videos -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - video_url = COALESCE($3, video_url), - duration = COALESCE($4, duration), - resolution = COALESCE($5, resolution), - visibility = COALESCE($6, visibility), - thumbnail = COALESCE($7, thumbnail), - display_order = COALESCE($8, display_order), - status = COALESCE($9, status), - vimeo_id = COALESCE($10, vimeo_id), - vimeo_embed_url = COALESCE($11, vimeo_embed_url), - vimeo_player_html = COALESCE($12, vimeo_player_html), - vimeo_status = COALESCE($13, vimeo_status), - video_host_provider = COALESCE($14, video_host_provider) -WHERE id = $15; - --- name: UpdateVimeoStatus :exec -UPDATE sub_course_videos -SET - vimeo_status = $1 -WHERE id = $2; - --- name: GetVideosByVimeoID :one -SELECT * FROM sub_course_videos -WHERE vimeo_id = $1; - --- name: ArchiveSubCourseVideo :exec -UPDATE sub_course_videos -SET status = 'ARCHIVED' -WHERE id = $1; - --- name: DeleteSubCourseVideo :exec -DELETE FROM sub_course_videos -WHERE id = $1; - --- name: ReorderSubCourseVideos :exec -UPDATE sub_course_videos -SET display_order = bulk.position -FROM ( - SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position -) AS bulk -WHERE sub_course_videos.id = bulk.id; diff --git a/db/query/sub_courses.sql b/db/query/sub_courses.sql deleted file mode 100644 index 80b3ce3..0000000 --- a/db/query/sub_courses.sql +++ /dev/null @@ -1,95 +0,0 @@ --- name: CreateSubCourse :one -INSERT INTO sub_courses ( - course_id, - title, - description, - thumbnail, - display_order, - level, - sub_level, - is_active -) -VALUES ($1, $2, $3, $4, COALESCE($5, 0), $6, $7, COALESCE($8, true)) -RETURNING *; - --- name: GetSubCourseByID :one -SELECT * -FROM sub_courses -WHERE id = $1; - --- name: GetSubCoursesByCourse :many -SELECT - COUNT(*) OVER () AS total_count, - id, - course_id, - title, - description, - thumbnail, - display_order, - level, - sub_level, - is_active -FROM sub_courses -WHERE course_id = $1 -ORDER BY display_order ASC, id ASC; - --- name: ListSubCoursesByCourse :many -SELECT - id, - course_id, - title, - description, - thumbnail, - display_order, - level, - sub_level, - is_active -FROM sub_courses -WHERE course_id = $1 - AND is_active = TRUE -ORDER BY display_order ASC, id ASC; - --- name: ListActiveSubCourses :many -SELECT - id, - course_id, - title, - description, - thumbnail, - display_order, - level, - sub_level, - is_active -FROM sub_courses -WHERE is_active = TRUE -ORDER BY display_order ASC; - --- name: UpdateSubCourse :exec -UPDATE sub_courses -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - thumbnail = COALESCE($3, thumbnail), - display_order = COALESCE($4, display_order), - level = COALESCE($5, level), - sub_level = COALESCE($6, sub_level), - is_active = COALESCE($7, is_active) -WHERE id = $8; - --- name: DeleteSubCourse :one -DELETE FROM sub_courses -WHERE id = $1 -RETURNING *; - --- name: DeactivateSubCourse :exec -UPDATE sub_courses -SET is_active = FALSE -WHERE id = $1; - --- name: ReorderSubCourses :exec -UPDATE sub_courses -SET display_order = bulk.position -FROM ( - SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position -) AS bulk -WHERE sub_courses.id = bulk.id; diff --git a/db/query/user_practice_progress.sql b/db/query/user_practice_progress.sql deleted file mode 100644 index 83cbfac..0000000 --- a/db/query/user_practice_progress.sql +++ /dev/null @@ -1,51 +0,0 @@ --- name: MarkPracticeCompleted :one -INSERT INTO user_practice_progress ( - user_id, - sub_course_id, - question_set_id, - completed_at, - updated_at -) -SELECT - @user_id::BIGINT, - qs.owner_id::BIGINT, - qs.id, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -FROM question_sets qs -WHERE qs.id = @question_set_id::BIGINT - AND qs.set_type = 'PRACTICE' - AND qs.owner_type = 'SUB_COURSE' - AND qs.status = 'PUBLISHED' -ON CONFLICT (user_id, question_set_id) -DO UPDATE SET - completed_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP -RETURNING *; - --- name: GetFirstIncompletePreviousPractice :one -SELECT - p.id, - p.title, - p.display_order -FROM question_sets target -JOIN question_sets p - ON p.owner_type = 'SUB_COURSE' - AND p.owner_id = target.owner_id - AND p.set_type = 'PRACTICE' - AND p.status = 'PUBLISHED' - AND ( - p.display_order < target.display_order OR - (p.display_order = target.display_order AND p.id < target.id) - ) -LEFT JOIN user_practice_progress upp - ON upp.question_set_id = p.id - AND upp.user_id = @user_id::BIGINT - AND upp.completed_at IS NOT NULL -WHERE target.id = @question_set_id::BIGINT - AND target.set_type = 'PRACTICE' - AND target.owner_type = 'SUB_COURSE' - AND target.status = 'PUBLISHED' - AND upp.question_set_id IS NULL -ORDER BY p.display_order ASC, p.id ASC -LIMIT 1; diff --git a/db/query/user_sub_course_progress.sql b/db/query/user_sub_course_progress.sql deleted file mode 100644 index 35fcabe..0000000 --- a/db/query/user_sub_course_progress.sql +++ /dev/null @@ -1,78 +0,0 @@ --- name: StartSubCourseProgress :one -INSERT INTO user_sub_course_progress (user_id, sub_course_id) -VALUES ($1, $2) -ON CONFLICT (user_id, sub_course_id) DO NOTHING -RETURNING *; - --- name: UpdateSubCourseProgress :exec -UPDATE user_sub_course_progress -SET - progress_percentage = $1, - updated_at = CURRENT_TIMESTAMP -WHERE user_id = $2 AND sub_course_id = $3; - --- name: CompleteSubCourse :exec -UPDATE user_sub_course_progress -SET - status = 'COMPLETED', - progress_percentage = 100, - completed_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP -WHERE user_id = $1 AND sub_course_id = $2; - --- name: GetUserSubCourseProgress :one -SELECT * FROM user_sub_course_progress -WHERE user_id = $1 AND sub_course_id = $2; - --- name: GetUserCourseProgress :many -SELECT - usp.id, - usp.user_id, - usp.sub_course_id, - usp.status, - usp.progress_percentage, - usp.started_at, - usp.completed_at, - usp.created_at, - usp.updated_at, - sc.title AS sub_course_title, - sc.level AS sub_course_level, - sc.display_order AS sub_course_display_order -FROM user_sub_course_progress usp -JOIN sub_courses sc ON sc.id = usp.sub_course_id -WHERE usp.user_id = $1 AND sc.course_id = $2 -ORDER BY sc.display_order; - --- name: GetSubCoursesWithProgressByCourse :many -SELECT - sc.id AS sub_course_id, - sc.title, - sc.description, - sc.thumbnail, - sc.display_order, - sc.level, - sc.is_active, - COALESCE(usp.status, 'NOT_STARTED') AS progress_status, - COALESCE(usp.progress_percentage, 0)::smallint AS progress_percentage, - usp.started_at, - usp.completed_at, - (SELECT COUNT(*)::bigint - FROM sub_course_prerequisites p - WHERE p.sub_course_id = sc.id - AND p.prerequisite_sub_course_id NOT IN ( - SELECT usp2.sub_course_id - FROM user_sub_course_progress usp2 - WHERE usp2.user_id = $1 - AND usp2.status = 'COMPLETED' - ) - ) AS unmet_prerequisites_count -FROM sub_courses sc -LEFT JOIN user_sub_course_progress usp - ON usp.sub_course_id = sc.id AND usp.user_id = $1 -WHERE sc.course_id = $2 - AND sc.is_active = true -ORDER BY sc.display_order; - --- name: DeleteUserSubCourseProgress :exec -DELETE FROM user_sub_course_progress -WHERE user_id = $1 AND sub_course_id = $2; diff --git a/db/query/user_sub_course_video_progress.sql b/db/query/user_sub_course_video_progress.sql deleted file mode 100644 index 8381e24..0000000 --- a/db/query/user_sub_course_video_progress.sql +++ /dev/null @@ -1,44 +0,0 @@ --- name: MarkVideoCompleted :one -INSERT INTO user_sub_course_video_progress ( - user_id, - sub_course_id, - video_id, - completed_at, - updated_at -) -SELECT - @user_id::BIGINT, - v.sub_course_id, - v.id, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -FROM sub_course_videos v -WHERE v.id = @video_id::BIGINT - AND v.status = 'PUBLISHED' -ON CONFLICT (user_id, video_id) -DO UPDATE SET - completed_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP -RETURNING *; - --- name: GetFirstIncompletePreviousVideo :one -SELECT - v.id, - v.title, - v.display_order -FROM sub_course_videos target -JOIN sub_course_videos v - ON v.sub_course_id = target.sub_course_id - AND v.status = 'PUBLISHED' - AND ( - v.display_order < target.display_order OR - (v.display_order = target.display_order AND v.id < target.id) - ) -LEFT JOIN user_sub_course_video_progress p - ON p.video_id = v.id - AND p.user_id = @user_id::BIGINT - AND p.completed_at IS NOT NULL -WHERE target.id = @video_id::BIGINT - AND p.video_id IS NULL -ORDER BY v.display_order ASC, v.id ASC -LIMIT 1; diff --git a/docs/docs.go b/docs/docs.go index 39b8775..a2a6c09 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -351,102 +351,6 @@ const docTemplate = `{ } } }, - "/api/v1/admin/users/{userId}/progress/courses/{courseId}": { - "get": { - "description": "Returns a target learner's progress for all sub-courses in a course, including lock status", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Get learner's course progress (admin)", - "parameters": [ - { - "type": "integer", - "description": "Learner User ID", - "name": "userId", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Course ID", - "name": "courseId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/admin/users/{userId}/progress/courses/{courseId}/summary": { - "get": { - "description": "Returns course-level aggregated progress metrics for a target learner", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Get learner's course progress summary (admin)", - "parameters": [ - { - "type": "integer", - "description": "Learner User ID", - "name": "userId", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Course ID", - "name": "courseId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, "/api/v1/admin/{id}": { "get": { "description": "Get a single admin by id", @@ -809,485 +713,16 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/categories": { + "/api/v1/course-management/courses/{courseId}/hierarchy": { "get": { - "description": "Returns a paginated list of all course categories", + "description": "Returns hierarchy nodes for one course including levels/modules/sub-modules", "produces": [ "application/json" ], "tags": [ - "course-categories" + "course-management" ], - "summary": "Get all course categories", - "parameters": [ - { - "type": "integer", - "default": 10, - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "integer", - "default": 0, - "description": "Offset", - "name": "offset", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "post": { - "description": "Creates a new course category with the provided name", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "course-categories" - ], - "summary": "Create a new course category", - "parameters": [ - { - "description": "Create category payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createCourseCategoryReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/categories/reorder": { - "put": { - "description": "Updates the display_order of course categories for drag-and-drop sorting", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "course-categories" - ], - "summary": "Reorder course categories", - "parameters": [ - { - "description": "Reorder payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.reorderReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/categories/{categoryId}/courses": { - "get": { - "description": "Returns a paginated list of courses under a specific category", - "produces": [ - "application/json" - ], - "tags": [ - "courses" - ], - "summary": "Get courses by category", - "parameters": [ - { - "type": "integer", - "description": "Category ID", - "name": "categoryId", - "in": "path", - "required": true - }, - { - "type": "integer", - "default": 10, - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "integer", - "default": 0, - "description": "Offset", - "name": "offset", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/categories/{id}": { - "get": { - "description": "Returns a single course category by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "course-categories" - ], - "summary": "Get course category by ID", - "parameters": [ - { - "type": "integer", - "description": "Category ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "put": { - "description": "Updates a course category's name and/or active status", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "course-categories" - ], - "summary": "Update course category", - "parameters": [ - { - "type": "integer", - "description": "Category ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update category payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateCourseCategoryReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Deletes a course category by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "course-categories" - ], - "summary": "Delete course category", - "parameters": [ - { - "type": "integer", - "description": "Category ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/courses": { - "post": { - "description": "Creates a new course under a specific category", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "courses" - ], - "summary": "Create a new course", - "parameters": [ - { - "description": "Create course payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createCourseReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/courses/reorder": { - "put": { - "description": "Updates the display_order of courses for drag-and-drop sorting", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "courses" - ], - "summary": "Reorder courses within a category", - "parameters": [ - { - "description": "Reorder payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.reorderReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/courses/{courseId}/learning-path": { - "get": { - "description": "Returns the complete learning path for a course including sub-courses (by level),\nvideo lessons, practices, prerequisites, and counts — structured for admin panel flexible path configuration", - "produces": [ - "application/json" - ], - "tags": [ - "learning-tree" - ], - "summary": "Get course learning path", - "parameters": [ - { - "type": "integer", - "description": "Course ID", - "name": "courseId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/courses/{courseId}/sub-courses": { - "get": { - "description": "Returns all sub-courses under a specific course", - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Get sub-courses by course", + "summary": "Get hierarchy for a course", "parameters": [ { "type": "integer", @@ -1319,25 +754,16 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/courses/{courseId}/sub-courses/list": { + "/api/v1/course-management/hierarchy": { "get": { - "description": "Returns a list of active sub-courses under a specific course", + "description": "Returns full hierarchy: category -\u003e sub-category -\u003e course", "produces": [ "application/json" ], "tags": [ - "sub-courses" - ], - "summary": "List active sub-courses by course", - "parameters": [ - { - "type": "integer", - "description": "Course ID", - "name": "courseId", - "in": "path", - "required": true - } + "course-management" ], + "summary": "Get unified course hierarchy", "responses": { "200": { "description": "OK", @@ -1345,12 +771,6 @@ const docTemplate = `{ "$ref": "#/definitions/domain.Response" } }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -1360,54 +780,9 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/courses/{id}": { - "get": { - "description": "Returns a single course by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "courses" - ], - "summary": "Get course by ID", - "parameters": [ - { - "type": "integer", - "description": "Course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "put": { - "description": "Updates a course's title, description, and/or active status", + "/api/v1/course-management/levels": { + "post": { + "description": "Creates a CEFR level under a course", "consumes": [ "application/json" ], @@ -1415,69 +790,23 @@ const docTemplate = `{ "application/json" ], "tags": [ - "courses" + "course-management" ], - "summary": "Update course", + "summary": "Create level", "parameters": [ { - "type": "integer", - "description": "Course ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update course payload", + "description": "Create level payload", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateCourseReq" + "$ref": "#/definitions/handlers.createLevelReq" } } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Deletes a course by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "courses" - ], - "summary": "Delete course", - "parameters": [ - { - "type": "integer", - "description": "Course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "schema": { "$ref": "#/definitions/domain.Response" } @@ -1497,30 +826,295 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/courses/{id}/thumbnail": { + "/api/v1/course-management/modules": { "post": { - "description": "Uploads and optimizes a thumbnail image, then updates the course", + "description": "Creates a module under a level", "consumes": [ - "multipart/form-data" + "application/json" ], "produces": [ "application/json" ], "tags": [ - "courses" + "course-management" ], - "summary": "Upload a thumbnail image for a course", + "summary": "Create module", "parameters": [ { - "type": "integer", - "description": "Course ID", - "name": "id", - "in": "path", - "required": true + "description": "Create module payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createModuleReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/sub-categories": { + "post": { + "description": "Creates a sub-category under a course category", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Create course sub-category", + "parameters": [ + { + "description": "Create sub-category payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createCourseSubCategoryReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/sub-module-lessons": { + "post": { + "description": "Links a question set lesson to a sub-module", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Attach lesson to sub-module", + "parameters": [ + { + "description": "Attach lesson payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.attachSubModuleLessonReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/sub-module-practices": { + "post": { + "description": "Creates a sub-module practice with metadata and linked question set", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Create practice under sub-module", + "parameters": [ + { + "description": "Create practice payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createSubModulePracticeReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/sub-module-videos": { + "post": { + "description": "Creates a video under a sub-module", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Create sub-module video", + "parameters": [ + { + "description": "Create sub-module video payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createSubModuleVideoReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/sub-modules": { + "post": { + "description": "Creates a sub-module under a module", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Create sub-module", + "parameters": [ + { + "description": "Create sub-module payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createSubModuleReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/files/audio": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "tags": [ + "files" + ], + "summary": "Upload an audio file", + "parameters": [ { "type": "file", - "description": "Thumbnail image file (jpg, png, webp)", + "description": "Audio file (mp3, wav, ogg, m4a, aac, webm)", "name": "file", "in": "formData", "required": true @@ -1532,554 +1126,30 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/domain.Response" } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } } } } }, - "/api/v1/course-management/learning-tree": { - "get": { - "description": "Returns the complete learning tree structure with courses and sub-courses", - "produces": [ - "application/json" - ], - "tags": [ - "learning-tree" - ], - "summary": "Get full learning tree", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/practices/reorder": { - "put": { - "description": "Updates the display_order of practices for drag-and-drop sorting", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "question-sets" - ], - "summary": "Reorder practices (question sets) within a sub-course", - "parameters": [ - { - "description": "Reorder payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.reorderReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses": { + "/api/v1/files/upload": { "post": { - "description": "Creates a new sub-course under a specific course", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Create a new sub-course", - "parameters": [ - { - "description": "Create sub-course payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createSubCourseReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/active": { - "get": { - "description": "Returns a list of all active sub-courses", - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "List all active sub-courses", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/reorder": { - "put": { - "description": "Updates the display_order of sub-courses for drag-and-drop sorting", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Reorder sub-courses within a course", - "parameters": [ - { - "description": "Reorder payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.reorderReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/{id}": { - "get": { - "description": "Returns a single sub-course by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Get sub-course by ID", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Deletes a sub-course by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Delete sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "patch": { - "description": "Updates a sub-course's fields", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Update sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update sub-course payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateSubCourseReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/{id}/deactivate": { - "put": { - "description": "Deactivates a sub-course by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Deactivate sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/{id}/prerequisites": { - "get": { - "description": "Returns all prerequisites for a sub-course", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Get sub-course prerequisites", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "post": { - "description": "Link a prerequisite sub-course that must be completed before accessing this sub-course", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Add prerequisite to sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Prerequisite sub-course ID", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.addPrerequisiteReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/{id}/prerequisites/{prerequisiteId}": { - "delete": { - "description": "Unlink a prerequisite from a sub-course", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Remove prerequisite from sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Prerequisite sub-course ID", - "name": "prerequisiteId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/{id}/thumbnail": { - "post": { - "description": "Uploads and optimizes a thumbnail image, then updates the sub-course", "consumes": [ "multipart/form-data" ], - "produces": [ - "application/json" - ], "tags": [ - "sub-courses" + "files" ], - "summary": "Upload a thumbnail image for a sub-course", + "summary": "Upload media file", "parameters": [ { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", + "type": "string", + "description": "Media type: image|audio|video", + "name": "media_type", + "in": "formData", "required": true }, { "type": "file", - "description": "Thumbnail image file (jpg, png, webp)", + "description": "Media file", "name": "file", "in": "formData", "required": true @@ -2091,38 +1161,22 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/domain.Response" } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } } } } }, - "/api/v1/course-management/sub-courses/{subCourseId}/videos": { + "/api/v1/files/url": { "get": { - "description": "Returns all videos under a specific sub-course", - "produces": [ - "application/json" - ], "tags": [ - "sub-course-videos" + "files" ], - "summary": "Get videos by sub-course", + "summary": "Get presigned URL for a file", "parameters": [ { - "type": "integer", - "description": "Sub-course ID", - "name": "subCourseId", - "in": "path", + "type": "string", + "description": "MinIO object key (e.g. profile_pictures/uuid.jpg)", + "name": "key", + "in": "query", "required": true } ], @@ -2132,66 +1186,13 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/domain.Response" } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } } } } }, - "/api/v1/course-management/sub-courses/{subCourseId}/videos/published": { - "get": { - "description": "Returns all published videos under a specific sub-course", - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Get published videos by sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "subCourseId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/videos": { + "/api/v1/internal/db/clear-course-management": { "post": { - "description": "Creates a new video under a specific sub-course", + "description": "Truncates course_categories, courses, and sub_courses (same scope as reset-reseed) without re-inserting seed SQL.", "consumes": [ "application/json" ], @@ -2199,63 +1200,23 @@ const docTemplate = `{ "application/json" ], "tags": [ - "sub-course-videos" + "internal" ], - "summary": "Create a new sub-course video", + "summary": "Clear course management hierarchy data only", "parameters": [ { - "description": "Create video payload", + "type": "string", + "description": "Optional token when DB_RESET_RESEED_TOKEN is set", + "name": "X-Seed-Reset-Token", + "in": "header" + }, + { + "description": "Confirmation payload", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.createSubCourseVideoReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/videos/reorder": { - "put": { - "description": "Updates the display_order of videos for drag-and-drop sorting", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Reorder videos within a sub-course", - "parameters": [ - { - "description": "Reorder payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.reorderReq" + "$ref": "#/definitions/handlers.clearCourseManagementReq" } } ], @@ -2272,6 +1233,12 @@ const docTemplate = `{ "$ref": "#/definitions/domain.ErrorResponse" } }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -2281,109 +1248,9 @@ const docTemplate = `{ } } }, - "/api/v1/course-management/videos/upload": { + "/api/v1/internal/db/reset-reseed": { "post": { - "description": "Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Upload a video file and create sub-course video", - "parameters": [ - { - "type": "file", - "description": "Video file", - "name": "file", - "in": "formData", - "required": true - }, - { - "type": "integer", - "description": "Sub-course ID", - "name": "sub_course_id", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Video title", - "name": "title", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Video description", - "name": "description", - "in": "formData" - }, - { - "type": "integer", - "description": "Duration in seconds", - "name": "duration", - "in": "formData" - }, - { - "type": "string", - "description": "Video resolution", - "name": "resolution", - "in": "formData" - }, - { - "type": "string", - "description": "Instructor ID", - "name": "instructor_id", - "in": "formData" - }, - { - "type": "string", - "description": "Thumbnail URL", - "name": "thumbnail", - "in": "formData" - }, - { - "type": "string", - "description": "Visibility", - "name": "visibility", - "in": "formData" - }, - { - "type": "integer", - "description": "Display order", - "name": "display_order", - "in": "formData" - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/videos/vimeo": { - "post": { - "description": "Creates a video by uploading to Vimeo from a source URL", + "description": "Dangerous operation: clears and reseeds only course_categories, courses, and sub_courses from seed SQL files.", "consumes": [ "application/json" ], @@ -2391,155 +1258,24 @@ const docTemplate = `{ "application/json" ], "tags": [ - "sub-course-videos" + "internal" ], - "summary": "Create a new sub-course video with Vimeo upload", + "summary": "Reset and reseed database", "parameters": [ { - "description": "Create Vimeo video payload", + "type": "string", + "description": "Reset token configured in DB_RESET_RESEED_TOKEN", + "name": "X-Seed-Reset-Token", + "in": "header", + "required": true + }, + { + "description": "Confirmation payload", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.createVimeoVideoReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/videos/vimeo/import": { - "post": { - "description": "Creates a video record from an existing Vimeo video ID", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Create a sub-course video from existing Vimeo video", - "parameters": [ - { - "description": "Create from Vimeo ID payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createVideoFromVimeoIDReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/videos/{id}": { - "get": { - "description": "Returns a single video by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Get sub-course video by ID", - "parameters": [ - { - "type": "integer", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "put": { - "description": "Updates a video's fields", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Update sub-course video", - "parameters": [ - { - "type": "integer", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update video payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateSubCourseVideoReq" + "$ref": "#/definitions/handlers.resetAndReseedReq" } } ], @@ -2556,82 +1292,8 @@ const docTemplate = `{ "$ref": "#/definitions/domain.ErrorResponse" } }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Archives a video by its ID (soft delete)", - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Delete sub-course video", - "parameters": [ - { - "type": "integer", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/videos/{id}/publish": { - "put": { - "description": "Publishes a video by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Publish sub-course video", - "parameters": [ - { - "type": "integer", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", + "403": { + "description": "Forbidden", "schema": { "$ref": "#/definitions/domain.ErrorResponse" } @@ -3956,23 +2618,43 @@ const docTemplate = `{ } } }, - "/api/v1/progress/courses/{courseId}": { + "/api/v1/practices/{practiceId}/questions": { "get": { - "description": "Returns the authenticated user's progress for all sub-courses in a course, including lock status", + "description": "Returns paginated questions for a practice(question-set), including AUDIO fields", "produces": [ "application/json" ], "tags": [ - "progression" + "question-set-items" ], - "summary": "Get user's course progress", + "summary": "Get questions by practice", "parameters": [ { "type": "integer", - "description": "Course ID", - "name": "courseId", + "description": "Practice(question-set) ID", + "name": "practiceId", "in": "path", "required": true + }, + { + "type": "string", + "description": "Question type filter (e.g. AUDIO)", + "name": "question_type", + "in": "query" + }, + { + "type": "integer", + "default": 10, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" } ], "responses": { @@ -3988,6 +2670,12 @@ const docTemplate = `{ "$ref": "#/definitions/domain.ErrorResponse" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -4050,241 +2738,6 @@ const docTemplate = `{ } } }, - "/api/v1/progress/sub-courses/{id}": { - "put": { - "description": "Update the progress percentage for a sub-course", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Update sub-course progress", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Progress update", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateProgressReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/progress/sub-courses/{id}/access": { - "get": { - "description": "Check if the authenticated user has completed all prerequisites for a sub-course", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Check sub-course access", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/progress/sub-courses/{id}/complete": { - "post": { - "description": "Mark a sub-course as completed for the authenticated user", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Complete a sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/progress/sub-courses/{id}/start": { - "post": { - "description": "Mark a sub-course as started for the authenticated user (checks prerequisites)", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Start a sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/progress/videos/{id}/complete": { - "post": { - "description": "Marks the given video as completed for the authenticated learner", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Mark sub-course video as completed", - "parameters": [ - { - "type": "integer", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, "/api/v1/question-sets": { "get": { "description": "Returns a paginated list of question sets filtered by type", @@ -5060,6 +3513,48 @@ const docTemplate = `{ } } }, + "/api/v1/questions/audio-answer": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "tags": [ + "questions" + ], + "summary": "Submit audio answer for a question", + "parameters": [ + { + "type": "integer", + "description": "Question ID", + "name": "question_id", + "in": "formData", + "required": true + }, + { + "type": "integer", + "description": "Question Set ID", + "name": "question_set_id", + "in": "formData", + "required": true + }, + { + "type": "file", + "description": "Audio recording", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/questions/search": { "get": { "description": "Search questions by text", @@ -10480,17 +8975,6 @@ const docTemplate = `{ } } }, - "handlers.addPrerequisiteReq": { - "type": "object", - "required": [ - "prerequisite_sub_course_id" - ], - "properties": { - "prerequisite_sub_course_id": { - "type": "integer" - } - } - }, "handlers.addQuestionToSetReq": { "type": "object", "required": [ @@ -10519,6 +9003,26 @@ const docTemplate = `{ } } }, + "handlers.attachSubModuleLessonReq": { + "type": "object", + "properties": { + "display_order": { + "type": "integer" + }, + "intro_video_url": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "question_set_id": { + "type": "integer" + }, + "sub_module_id": { + "type": "integer" + } + } + }, "handlers.autoRenewReq": { "type": "object", "properties": { @@ -10545,23 +9049,16 @@ const docTemplate = `{ } } }, - "handlers.createCourseCategoryReq": { + "handlers.clearCourseManagementReq": { "type": "object", - "required": [ - "name" - ], "properties": { - "name": { + "confirm": { "type": "string" } } }, - "handlers.createCourseReq": { + "handlers.createCourseSubCategoryReq": { "type": "object", - "required": [ - "category_id", - "title" - ], "properties": { "category_id": { "type": "integer" @@ -10569,13 +9066,13 @@ const docTemplate = `{ "description": { "type": "string" }, - "intro_video_url": { - "type": "string" + "display_order": { + "type": "integer" }, - "thumbnail": { - "type": "string" + "is_active": { + "type": "boolean" }, - "title": { + "name": { "type": "string" } } @@ -10603,6 +9100,43 @@ const docTemplate = `{ } } }, + "handlers.createLevelReq": { + "type": "object", + "properties": { + "cefr_level": { + "type": "string" + }, + "course_id": { + "type": "integer" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + } + } + }, + "handlers.createModuleReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "level_id": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, "handlers.createPlanReq": { "type": "object", "required": [ @@ -10717,6 +9251,9 @@ const docTemplate = `{ "description": { "type": "string" }, + "intro_video_url": { + "type": "string" + }, "owner_id": { "type": "integer" }, @@ -10756,31 +9293,26 @@ const docTemplate = `{ } } }, - "handlers.createSubCourseReq": { + "handlers.createSubModulePracticeReq": { "type": "object", - "required": [ - "course_id", - "level", - "sub_level", - "title" - ], "properties": { - "course_id": { - "type": "integer" - }, "description": { "type": "string" }, "display_order": { "type": "integer" }, - "level": { - "description": "BEGINNER, INTERMEDIATE, ADVANCED", + "intro_video_url": { "type": "string" }, - "sub_level": { - "description": "A1..C3 depending on level", - "type": "string" + "is_active": { + "type": "boolean" + }, + "question_set_id": { + "type": "integer" + }, + "sub_module_id": { + "type": "integer" }, "thumbnail": { "type": "string" @@ -10790,14 +9322,28 @@ const docTemplate = `{ } } }, - "handlers.createSubCourseVideoReq": { + "handlers.createSubModuleReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "module_id": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "handlers.createSubModuleVideoReq": { "type": "object", - "required": [ - "duration", - "sub_course_id", - "title", - "video_url" - ], "properties": { "description": { "type": "string" @@ -10815,10 +9361,9 @@ const docTemplate = `{ "type": "string" }, "status": { - "description": "DRAFT, PUBLISHED, INACTIVE, ARCHIVED", "type": "string" }, - "sub_course_id": { + "sub_module_id": { "type": "integer" }, "thumbnail": { @@ -10835,78 +9380,6 @@ const docTemplate = `{ } } }, - "handlers.createVideoFromVimeoIDReq": { - "type": "object", - "required": [ - "sub_course_id", - "title", - "vimeo_video_id" - ], - "properties": { - "description": { - "type": "string" - }, - "display_order": { - "type": "integer" - }, - "instructor_id": { - "type": "string" - }, - "sub_course_id": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "vimeo_video_id": { - "type": "string" - } - } - }, - "handlers.createVimeoVideoReq": { - "type": "object", - "required": [ - "file_size", - "source_url", - "sub_course_id", - "title" - ], - "properties": { - "description": { - "type": "string" - }, - "display_order": { - "type": "integer" - }, - "duration": { - "type": "integer" - }, - "file_size": { - "type": "integer" - }, - "instructor_id": { - "type": "string" - }, - "resolution": { - "type": "string" - }, - "source_url": { - "type": "string" - }, - "sub_course_id": { - "type": "integer" - }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - }, - "visibility": { - "type": "string" - } - } - }, "handlers.initiateDirectPaymentReq": { "type": "object", "required": [ @@ -11066,32 +9539,11 @@ const docTemplate = `{ } } }, - "handlers.reorderItem": { + "handlers.resetAndReseedReq": { "type": "object", - "required": [ - "id" - ], "properties": { - "id": { - "type": "integer" - }, - "position": { - "type": "integer" - } - } - }, - "handlers.reorderReq": { - "type": "object", - "required": [ - "items" - ], - "properties": { - "items": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/handlers.reorderItem" - } + "confirm": { + "type": "string" } } }, @@ -11206,37 +9658,6 @@ const docTemplate = `{ } } }, - "handlers.updateCourseCategoryReq": { - "type": "object", - "properties": { - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - } - } - }, - "handlers.updateCourseReq": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "intro_video_url": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, "handlers.updateIssueStatusReq": { "type": "object", "required": [ @@ -11280,19 +9701,6 @@ const docTemplate = `{ } } }, - "handlers.updateProgressReq": { - "type": "object", - "required": [ - "progress_percentage" - ], - "properties": { - "progress_percentage": { - "type": "integer", - "maximum": 100, - "minimum": 0 - } - } - }, "handlers.updateQuestionOrderReq": { "type": "object", "required": [ @@ -11363,6 +9771,9 @@ const docTemplate = `{ "description": { "type": "string" }, + "intro_video_url": { + "type": "string" + }, "passing_score": { "type": "integer" }, @@ -11386,65 +9797,6 @@ const docTemplate = `{ } } }, - "handlers.updateSubCourseReq": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "display_order": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "level": { - "type": "string" - }, - "sub_level": { - "type": "string" - }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "handlers.updateSubCourseVideoReq": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "display_order": { - "type": "integer" - }, - "duration": { - "type": "integer" - }, - "resolution": { - "type": "string" - }, - "status": { - "description": "DRAFT, PUBLISHED, INACTIVE, ARCHIVED", - "type": "string" - }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - }, - "video_url": { - "type": "string" - }, - "visibility": { - "type": "string" - } - } - }, "handlers.verifyOTPReq": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index f313fa6..d2a1bef 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -343,102 +343,6 @@ } } }, - "/api/v1/admin/users/{userId}/progress/courses/{courseId}": { - "get": { - "description": "Returns a target learner's progress for all sub-courses in a course, including lock status", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Get learner's course progress (admin)", - "parameters": [ - { - "type": "integer", - "description": "Learner User ID", - "name": "userId", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Course ID", - "name": "courseId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/admin/users/{userId}/progress/courses/{courseId}/summary": { - "get": { - "description": "Returns course-level aggregated progress metrics for a target learner", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Get learner's course progress summary (admin)", - "parameters": [ - { - "type": "integer", - "description": "Learner User ID", - "name": "userId", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Course ID", - "name": "courseId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, "/api/v1/admin/{id}": { "get": { "description": "Get a single admin by id", @@ -801,485 +705,16 @@ } } }, - "/api/v1/course-management/categories": { + "/api/v1/course-management/courses/{courseId}/hierarchy": { "get": { - "description": "Returns a paginated list of all course categories", + "description": "Returns hierarchy nodes for one course including levels/modules/sub-modules", "produces": [ "application/json" ], "tags": [ - "course-categories" + "course-management" ], - "summary": "Get all course categories", - "parameters": [ - { - "type": "integer", - "default": 10, - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "integer", - "default": 0, - "description": "Offset", - "name": "offset", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "post": { - "description": "Creates a new course category with the provided name", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "course-categories" - ], - "summary": "Create a new course category", - "parameters": [ - { - "description": "Create category payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createCourseCategoryReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/categories/reorder": { - "put": { - "description": "Updates the display_order of course categories for drag-and-drop sorting", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "course-categories" - ], - "summary": "Reorder course categories", - "parameters": [ - { - "description": "Reorder payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.reorderReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/categories/{categoryId}/courses": { - "get": { - "description": "Returns a paginated list of courses under a specific category", - "produces": [ - "application/json" - ], - "tags": [ - "courses" - ], - "summary": "Get courses by category", - "parameters": [ - { - "type": "integer", - "description": "Category ID", - "name": "categoryId", - "in": "path", - "required": true - }, - { - "type": "integer", - "default": 10, - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "integer", - "default": 0, - "description": "Offset", - "name": "offset", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/categories/{id}": { - "get": { - "description": "Returns a single course category by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "course-categories" - ], - "summary": "Get course category by ID", - "parameters": [ - { - "type": "integer", - "description": "Category ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "put": { - "description": "Updates a course category's name and/or active status", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "course-categories" - ], - "summary": "Update course category", - "parameters": [ - { - "type": "integer", - "description": "Category ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update category payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateCourseCategoryReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Deletes a course category by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "course-categories" - ], - "summary": "Delete course category", - "parameters": [ - { - "type": "integer", - "description": "Category ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/courses": { - "post": { - "description": "Creates a new course under a specific category", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "courses" - ], - "summary": "Create a new course", - "parameters": [ - { - "description": "Create course payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createCourseReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/courses/reorder": { - "put": { - "description": "Updates the display_order of courses for drag-and-drop sorting", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "courses" - ], - "summary": "Reorder courses within a category", - "parameters": [ - { - "description": "Reorder payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.reorderReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/courses/{courseId}/learning-path": { - "get": { - "description": "Returns the complete learning path for a course including sub-courses (by level),\nvideo lessons, practices, prerequisites, and counts — structured for admin panel flexible path configuration", - "produces": [ - "application/json" - ], - "tags": [ - "learning-tree" - ], - "summary": "Get course learning path", - "parameters": [ - { - "type": "integer", - "description": "Course ID", - "name": "courseId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/courses/{courseId}/sub-courses": { - "get": { - "description": "Returns all sub-courses under a specific course", - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Get sub-courses by course", + "summary": "Get hierarchy for a course", "parameters": [ { "type": "integer", @@ -1311,25 +746,16 @@ } } }, - "/api/v1/course-management/courses/{courseId}/sub-courses/list": { + "/api/v1/course-management/hierarchy": { "get": { - "description": "Returns a list of active sub-courses under a specific course", + "description": "Returns full hierarchy: category -\u003e sub-category -\u003e course", "produces": [ "application/json" ], "tags": [ - "sub-courses" - ], - "summary": "List active sub-courses by course", - "parameters": [ - { - "type": "integer", - "description": "Course ID", - "name": "courseId", - "in": "path", - "required": true - } + "course-management" ], + "summary": "Get unified course hierarchy", "responses": { "200": { "description": "OK", @@ -1337,12 +763,6 @@ "$ref": "#/definitions/domain.Response" } }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -1352,54 +772,9 @@ } } }, - "/api/v1/course-management/courses/{id}": { - "get": { - "description": "Returns a single course by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "courses" - ], - "summary": "Get course by ID", - "parameters": [ - { - "type": "integer", - "description": "Course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "put": { - "description": "Updates a course's title, description, and/or active status", + "/api/v1/course-management/levels": { + "post": { + "description": "Creates a CEFR level under a course", "consumes": [ "application/json" ], @@ -1407,69 +782,23 @@ "application/json" ], "tags": [ - "courses" + "course-management" ], - "summary": "Update course", + "summary": "Create level", "parameters": [ { - "type": "integer", - "description": "Course ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update course payload", + "description": "Create level payload", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.updateCourseReq" + "$ref": "#/definitions/handlers.createLevelReq" } } ], "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Deletes a course by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "courses" - ], - "summary": "Delete course", - "parameters": [ - { - "type": "integer", - "description": "Course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", + "201": { + "description": "Created", "schema": { "$ref": "#/definitions/domain.Response" } @@ -1489,30 +818,295 @@ } } }, - "/api/v1/course-management/courses/{id}/thumbnail": { + "/api/v1/course-management/modules": { "post": { - "description": "Uploads and optimizes a thumbnail image, then updates the course", + "description": "Creates a module under a level", "consumes": [ - "multipart/form-data" + "application/json" ], "produces": [ "application/json" ], "tags": [ - "courses" + "course-management" ], - "summary": "Upload a thumbnail image for a course", + "summary": "Create module", "parameters": [ { - "type": "integer", - "description": "Course ID", - "name": "id", - "in": "path", - "required": true + "description": "Create module payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createModuleReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/sub-categories": { + "post": { + "description": "Creates a sub-category under a course category", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Create course sub-category", + "parameters": [ + { + "description": "Create sub-category payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createCourseSubCategoryReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/sub-module-lessons": { + "post": { + "description": "Links a question set lesson to a sub-module", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Attach lesson to sub-module", + "parameters": [ + { + "description": "Attach lesson payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.attachSubModuleLessonReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/sub-module-practices": { + "post": { + "description": "Creates a sub-module practice with metadata and linked question set", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Create practice under sub-module", + "parameters": [ + { + "description": "Create practice payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createSubModulePracticeReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/sub-module-videos": { + "post": { + "description": "Creates a video under a sub-module", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Create sub-module video", + "parameters": [ + { + "description": "Create sub-module video payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createSubModuleVideoReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/course-management/sub-modules": { + "post": { + "description": "Creates a sub-module under a module", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "course-management" + ], + "summary": "Create sub-module", + "parameters": [ + { + "description": "Create sub-module payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createSubModuleReq" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/domain.Response" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + } + } + } + }, + "/api/v1/files/audio": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "tags": [ + "files" + ], + "summary": "Upload an audio file", + "parameters": [ { "type": "file", - "description": "Thumbnail image file (jpg, png, webp)", + "description": "Audio file (mp3, wav, ogg, m4a, aac, webm)", "name": "file", "in": "formData", "required": true @@ -1524,554 +1118,30 @@ "schema": { "$ref": "#/definitions/domain.Response" } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } } } } }, - "/api/v1/course-management/learning-tree": { - "get": { - "description": "Returns the complete learning tree structure with courses and sub-courses", - "produces": [ - "application/json" - ], - "tags": [ - "learning-tree" - ], - "summary": "Get full learning tree", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/practices/reorder": { - "put": { - "description": "Updates the display_order of practices for drag-and-drop sorting", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "question-sets" - ], - "summary": "Reorder practices (question sets) within a sub-course", - "parameters": [ - { - "description": "Reorder payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.reorderReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses": { + "/api/v1/files/upload": { "post": { - "description": "Creates a new sub-course under a specific course", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Create a new sub-course", - "parameters": [ - { - "description": "Create sub-course payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createSubCourseReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/active": { - "get": { - "description": "Returns a list of all active sub-courses", - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "List all active sub-courses", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/reorder": { - "put": { - "description": "Updates the display_order of sub-courses for drag-and-drop sorting", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Reorder sub-courses within a course", - "parameters": [ - { - "description": "Reorder payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.reorderReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/{id}": { - "get": { - "description": "Returns a single sub-course by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Get sub-course by ID", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Deletes a sub-course by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Delete sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "patch": { - "description": "Updates a sub-course's fields", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Update sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update sub-course payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateSubCourseReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/{id}/deactivate": { - "put": { - "description": "Deactivates a sub-course by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "sub-courses" - ], - "summary": "Deactivate sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/{id}/prerequisites": { - "get": { - "description": "Returns all prerequisites for a sub-course", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Get sub-course prerequisites", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "post": { - "description": "Link a prerequisite sub-course that must be completed before accessing this sub-course", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Add prerequisite to sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Prerequisite sub-course ID", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.addPrerequisiteReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/{id}/prerequisites/{prerequisiteId}": { - "delete": { - "description": "Unlink a prerequisite from a sub-course", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Remove prerequisite from sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "Prerequisite sub-course ID", - "name": "prerequisiteId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/sub-courses/{id}/thumbnail": { - "post": { - "description": "Uploads and optimizes a thumbnail image, then updates the sub-course", "consumes": [ "multipart/form-data" ], - "produces": [ - "application/json" - ], "tags": [ - "sub-courses" + "files" ], - "summary": "Upload a thumbnail image for a sub-course", + "summary": "Upload media file", "parameters": [ { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", + "type": "string", + "description": "Media type: image|audio|video", + "name": "media_type", + "in": "formData", "required": true }, { "type": "file", - "description": "Thumbnail image file (jpg, png, webp)", + "description": "Media file", "name": "file", "in": "formData", "required": true @@ -2083,38 +1153,22 @@ "schema": { "$ref": "#/definitions/domain.Response" } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } } } } }, - "/api/v1/course-management/sub-courses/{subCourseId}/videos": { + "/api/v1/files/url": { "get": { - "description": "Returns all videos under a specific sub-course", - "produces": [ - "application/json" - ], "tags": [ - "sub-course-videos" + "files" ], - "summary": "Get videos by sub-course", + "summary": "Get presigned URL for a file", "parameters": [ { - "type": "integer", - "description": "Sub-course ID", - "name": "subCourseId", - "in": "path", + "type": "string", + "description": "MinIO object key (e.g. profile_pictures/uuid.jpg)", + "name": "key", + "in": "query", "required": true } ], @@ -2124,66 +1178,13 @@ "schema": { "$ref": "#/definitions/domain.Response" } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } } } } }, - "/api/v1/course-management/sub-courses/{subCourseId}/videos/published": { - "get": { - "description": "Returns all published videos under a specific sub-course", - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Get published videos by sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "subCourseId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/videos": { + "/api/v1/internal/db/clear-course-management": { "post": { - "description": "Creates a new video under a specific sub-course", + "description": "Truncates course_categories, courses, and sub_courses (same scope as reset-reseed) without re-inserting seed SQL.", "consumes": [ "application/json" ], @@ -2191,63 +1192,23 @@ "application/json" ], "tags": [ - "sub-course-videos" + "internal" ], - "summary": "Create a new sub-course video", + "summary": "Clear course management hierarchy data only", "parameters": [ { - "description": "Create video payload", + "type": "string", + "description": "Optional token when DB_RESET_RESEED_TOKEN is set", + "name": "X-Seed-Reset-Token", + "in": "header" + }, + { + "description": "Confirmation payload", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.createSubCourseVideoReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/videos/reorder": { - "put": { - "description": "Updates the display_order of videos for drag-and-drop sorting", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Reorder videos within a sub-course", - "parameters": [ - { - "description": "Reorder payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.reorderReq" + "$ref": "#/definitions/handlers.clearCourseManagementReq" } } ], @@ -2264,6 +1225,12 @@ "$ref": "#/definitions/domain.ErrorResponse" } }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -2273,109 +1240,9 @@ } } }, - "/api/v1/course-management/videos/upload": { + "/api/v1/internal/db/reset-reseed": { "post": { - "description": "Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Upload a video file and create sub-course video", - "parameters": [ - { - "type": "file", - "description": "Video file", - "name": "file", - "in": "formData", - "required": true - }, - { - "type": "integer", - "description": "Sub-course ID", - "name": "sub_course_id", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Video title", - "name": "title", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "Video description", - "name": "description", - "in": "formData" - }, - { - "type": "integer", - "description": "Duration in seconds", - "name": "duration", - "in": "formData" - }, - { - "type": "string", - "description": "Video resolution", - "name": "resolution", - "in": "formData" - }, - { - "type": "string", - "description": "Instructor ID", - "name": "instructor_id", - "in": "formData" - }, - { - "type": "string", - "description": "Thumbnail URL", - "name": "thumbnail", - "in": "formData" - }, - { - "type": "string", - "description": "Visibility", - "name": "visibility", - "in": "formData" - }, - { - "type": "integer", - "description": "Display order", - "name": "display_order", - "in": "formData" - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/videos/vimeo": { - "post": { - "description": "Creates a video by uploading to Vimeo from a source URL", + "description": "Dangerous operation: clears and reseeds only course_categories, courses, and sub_courses from seed SQL files.", "consumes": [ "application/json" ], @@ -2383,155 +1250,24 @@ "application/json" ], "tags": [ - "sub-course-videos" + "internal" ], - "summary": "Create a new sub-course video with Vimeo upload", + "summary": "Reset and reseed database", "parameters": [ { - "description": "Create Vimeo video payload", + "type": "string", + "description": "Reset token configured in DB_RESET_RESEED_TOKEN", + "name": "X-Seed-Reset-Token", + "in": "header", + "required": true + }, + { + "description": "Confirmation payload", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.createVimeoVideoReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/videos/vimeo/import": { - "post": { - "description": "Creates a video record from an existing Vimeo video ID", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Create a sub-course video from existing Vimeo video", - "parameters": [ - { - "description": "Create from Vimeo ID payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.createVideoFromVimeoIDReq" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/videos/{id}": { - "get": { - "description": "Returns a single video by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Get sub-course video by ID", - "parameters": [ - { - "type": "integer", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "put": { - "description": "Updates a video's fields", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Update sub-course video", - "parameters": [ - { - "type": "integer", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Update video payload", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateSubCourseVideoReq" + "$ref": "#/definitions/handlers.resetAndReseedReq" } } ], @@ -2548,82 +1284,8 @@ "$ref": "#/definitions/domain.ErrorResponse" } }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - }, - "delete": { - "description": "Archives a video by its ID (soft delete)", - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Delete sub-course video", - "parameters": [ - { - "type": "integer", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/course-management/videos/{id}/publish": { - "put": { - "description": "Publishes a video by its ID", - "produces": [ - "application/json" - ], - "tags": [ - "sub-course-videos" - ], - "summary": "Publish sub-course video", - "parameters": [ - { - "type": "integer", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", + "403": { + "description": "Forbidden", "schema": { "$ref": "#/definitions/domain.ErrorResponse" } @@ -3948,23 +2610,43 @@ } } }, - "/api/v1/progress/courses/{courseId}": { + "/api/v1/practices/{practiceId}/questions": { "get": { - "description": "Returns the authenticated user's progress for all sub-courses in a course, including lock status", + "description": "Returns paginated questions for a practice(question-set), including AUDIO fields", "produces": [ "application/json" ], "tags": [ - "progression" + "question-set-items" ], - "summary": "Get user's course progress", + "summary": "Get questions by practice", "parameters": [ { "type": "integer", - "description": "Course ID", - "name": "courseId", + "description": "Practice(question-set) ID", + "name": "practiceId", "in": "path", "required": true + }, + { + "type": "string", + "description": "Question type filter (e.g. AUDIO)", + "name": "question_type", + "in": "query" + }, + { + "type": "integer", + "default": 10, + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Offset", + "name": "offset", + "in": "query" } ], "responses": { @@ -3980,6 +2662,12 @@ "$ref": "#/definitions/domain.ErrorResponse" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/domain.ErrorResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -4042,241 +2730,6 @@ } } }, - "/api/v1/progress/sub-courses/{id}": { - "put": { - "description": "Update the progress percentage for a sub-course", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Update sub-course progress", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Progress update", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handlers.updateProgressReq" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/progress/sub-courses/{id}/access": { - "get": { - "description": "Check if the authenticated user has completed all prerequisites for a sub-course", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Check sub-course access", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/progress/sub-courses/{id}/complete": { - "post": { - "description": "Mark a sub-course as completed for the authenticated user", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Complete a sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/progress/sub-courses/{id}/start": { - "post": { - "description": "Mark a sub-course as started for the authenticated user (checks prerequisites)", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Start a sub-course", - "parameters": [ - { - "type": "integer", - "description": "Sub-course ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, - "/api/v1/progress/videos/{id}/complete": { - "post": { - "description": "Marks the given video as completed for the authenticated learner", - "produces": [ - "application/json" - ], - "tags": [ - "progression" - ], - "summary": "Mark sub-course video as completed", - "parameters": [ - { - "type": "integer", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/domain.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/domain.ErrorResponse" - } - } - } - } - }, "/api/v1/question-sets": { "get": { "description": "Returns a paginated list of question sets filtered by type", @@ -5052,6 +3505,48 @@ } } }, + "/api/v1/questions/audio-answer": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "tags": [ + "questions" + ], + "summary": "Submit audio answer for a question", + "parameters": [ + { + "type": "integer", + "description": "Question ID", + "name": "question_id", + "in": "formData", + "required": true + }, + { + "type": "integer", + "description": "Question Set ID", + "name": "question_set_id", + "in": "formData", + "required": true + }, + { + "type": "file", + "description": "Audio recording", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.Response" + } + } + } + } + }, "/api/v1/questions/search": { "get": { "description": "Search questions by text", @@ -10472,17 +8967,6 @@ } } }, - "handlers.addPrerequisiteReq": { - "type": "object", - "required": [ - "prerequisite_sub_course_id" - ], - "properties": { - "prerequisite_sub_course_id": { - "type": "integer" - } - } - }, "handlers.addQuestionToSetReq": { "type": "object", "required": [ @@ -10511,6 +8995,26 @@ } } }, + "handlers.attachSubModuleLessonReq": { + "type": "object", + "properties": { + "display_order": { + "type": "integer" + }, + "intro_video_url": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "question_set_id": { + "type": "integer" + }, + "sub_module_id": { + "type": "integer" + } + } + }, "handlers.autoRenewReq": { "type": "object", "properties": { @@ -10537,23 +9041,16 @@ } } }, - "handlers.createCourseCategoryReq": { + "handlers.clearCourseManagementReq": { "type": "object", - "required": [ - "name" - ], "properties": { - "name": { + "confirm": { "type": "string" } } }, - "handlers.createCourseReq": { + "handlers.createCourseSubCategoryReq": { "type": "object", - "required": [ - "category_id", - "title" - ], "properties": { "category_id": { "type": "integer" @@ -10561,13 +9058,13 @@ "description": { "type": "string" }, - "intro_video_url": { - "type": "string" + "display_order": { + "type": "integer" }, - "thumbnail": { - "type": "string" + "is_active": { + "type": "boolean" }, - "title": { + "name": { "type": "string" } } @@ -10595,6 +9092,43 @@ } } }, + "handlers.createLevelReq": { + "type": "object", + "properties": { + "cefr_level": { + "type": "string" + }, + "course_id": { + "type": "integer" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + } + } + }, + "handlers.createModuleReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "level_id": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, "handlers.createPlanReq": { "type": "object", "required": [ @@ -10709,6 +9243,9 @@ "description": { "type": "string" }, + "intro_video_url": { + "type": "string" + }, "owner_id": { "type": "integer" }, @@ -10748,31 +9285,26 @@ } } }, - "handlers.createSubCourseReq": { + "handlers.createSubModulePracticeReq": { "type": "object", - "required": [ - "course_id", - "level", - "sub_level", - "title" - ], "properties": { - "course_id": { - "type": "integer" - }, "description": { "type": "string" }, "display_order": { "type": "integer" }, - "level": { - "description": "BEGINNER, INTERMEDIATE, ADVANCED", + "intro_video_url": { "type": "string" }, - "sub_level": { - "description": "A1..C3 depending on level", - "type": "string" + "is_active": { + "type": "boolean" + }, + "question_set_id": { + "type": "integer" + }, + "sub_module_id": { + "type": "integer" }, "thumbnail": { "type": "string" @@ -10782,14 +9314,28 @@ } } }, - "handlers.createSubCourseVideoReq": { + "handlers.createSubModuleReq": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "module_id": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "handlers.createSubModuleVideoReq": { "type": "object", - "required": [ - "duration", - "sub_course_id", - "title", - "video_url" - ], "properties": { "description": { "type": "string" @@ -10807,10 +9353,9 @@ "type": "string" }, "status": { - "description": "DRAFT, PUBLISHED, INACTIVE, ARCHIVED", "type": "string" }, - "sub_course_id": { + "sub_module_id": { "type": "integer" }, "thumbnail": { @@ -10827,78 +9372,6 @@ } } }, - "handlers.createVideoFromVimeoIDReq": { - "type": "object", - "required": [ - "sub_course_id", - "title", - "vimeo_video_id" - ], - "properties": { - "description": { - "type": "string" - }, - "display_order": { - "type": "integer" - }, - "instructor_id": { - "type": "string" - }, - "sub_course_id": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "vimeo_video_id": { - "type": "string" - } - } - }, - "handlers.createVimeoVideoReq": { - "type": "object", - "required": [ - "file_size", - "source_url", - "sub_course_id", - "title" - ], - "properties": { - "description": { - "type": "string" - }, - "display_order": { - "type": "integer" - }, - "duration": { - "type": "integer" - }, - "file_size": { - "type": "integer" - }, - "instructor_id": { - "type": "string" - }, - "resolution": { - "type": "string" - }, - "source_url": { - "type": "string" - }, - "sub_course_id": { - "type": "integer" - }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - }, - "visibility": { - "type": "string" - } - } - }, "handlers.initiateDirectPaymentReq": { "type": "object", "required": [ @@ -11058,32 +9531,11 @@ } } }, - "handlers.reorderItem": { + "handlers.resetAndReseedReq": { "type": "object", - "required": [ - "id" - ], "properties": { - "id": { - "type": "integer" - }, - "position": { - "type": "integer" - } - } - }, - "handlers.reorderReq": { - "type": "object", - "required": [ - "items" - ], - "properties": { - "items": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/definitions/handlers.reorderItem" - } + "confirm": { + "type": "string" } } }, @@ -11198,37 +9650,6 @@ } } }, - "handlers.updateCourseCategoryReq": { - "type": "object", - "properties": { - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - } - } - }, - "handlers.updateCourseReq": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "intro_video_url": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, "handlers.updateIssueStatusReq": { "type": "object", "required": [ @@ -11272,19 +9693,6 @@ } } }, - "handlers.updateProgressReq": { - "type": "object", - "required": [ - "progress_percentage" - ], - "properties": { - "progress_percentage": { - "type": "integer", - "maximum": 100, - "minimum": 0 - } - } - }, "handlers.updateQuestionOrderReq": { "type": "object", "required": [ @@ -11355,6 +9763,9 @@ "description": { "type": "string" }, + "intro_video_url": { + "type": "string" + }, "passing_score": { "type": "integer" }, @@ -11378,65 +9789,6 @@ } } }, - "handlers.updateSubCourseReq": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "display_order": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "level": { - "type": "string" - }, - "sub_level": { - "type": "string" - }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "handlers.updateSubCourseVideoReq": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "display_order": { - "type": "integer" - }, - "duration": { - "type": "integer" - }, - "resolution": { - "type": "string" - }, - "status": { - "description": "DRAFT, PUBLISHED, INACTIVE, ARCHIVED", - "type": "string" - }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - }, - "video_url": { - "type": "string" - }, - "visibility": { - "type": "string" - } - } - }, "handlers.verifyOTPReq": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 9ab583b..5336f56 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -893,13 +893,6 @@ definitions: width: type: integer type: object - handlers.addPrerequisiteReq: - properties: - prerequisite_sub_course_id: - type: integer - required: - - prerequisite_sub_course_id - type: object handlers.addQuestionToSetReq: properties: display_order: @@ -918,6 +911,19 @@ definitions: required: - user_id type: object + handlers.attachSubModuleLessonReq: + properties: + display_order: + type: integer + intro_video_url: + type: string + is_active: + type: boolean + question_set_id: + type: integer + sub_module_id: + type: integer + type: object handlers.autoRenewReq: properties: auto_renew: @@ -936,28 +942,23 @@ definitions: - current_password - new_password type: object - handlers.createCourseCategoryReq: + handlers.clearCourseManagementReq: properties: - name: + confirm: type: string - required: - - name type: object - handlers.createCourseReq: + handlers.createCourseSubCategoryReq: properties: category_id: type: integer description: type: string - intro_video_url: + display_order: + type: integer + is_active: + type: boolean + name: type: string - thumbnail: - type: string - title: - type: string - required: - - category_id - - title type: object handlers.createIssueReq: properties: @@ -975,6 +976,30 @@ definitions: - issue_type - subject type: object + handlers.createLevelReq: + properties: + cefr_level: + type: string + course_id: + type: integer + display_order: + type: integer + is_active: + type: boolean + type: object + handlers.createModuleReq: + properties: + description: + type: string + display_order: + type: integer + is_active: + type: boolean + level_id: + type: integer + title: + type: string + type: object handlers.createPlanReq: properties: currency: @@ -1052,6 +1077,8 @@ definitions: type: string description: type: string + intro_video_url: + type: string owner_id: type: integer owner_type: @@ -1082,31 +1109,39 @@ definitions: - set_type - title type: object - handlers.createSubCourseReq: + handlers.createSubModulePracticeReq: properties: - course_id: - type: integer description: type: string display_order: type: integer - level: - description: BEGINNER, INTERMEDIATE, ADVANCED - type: string - sub_level: - description: A1..C3 depending on level + intro_video_url: type: string + is_active: + type: boolean + question_set_id: + type: integer + sub_module_id: + type: integer thumbnail: type: string title: type: string - required: - - course_id - - level - - sub_level - - title type: object - handlers.createSubCourseVideoReq: + handlers.createSubModuleReq: + properties: + description: + type: string + display_order: + type: integer + is_active: + type: boolean + module_id: + type: integer + title: + type: string + type: object + handlers.createSubModuleVideoReq: properties: description: type: string @@ -1119,9 +1154,8 @@ definitions: resolution: type: string status: - description: DRAFT, PUBLISHED, INACTIVE, ARCHIVED type: string - sub_course_id: + sub_module_id: type: integer thumbnail: type: string @@ -1131,60 +1165,6 @@ definitions: type: string visibility: type: string - required: - - duration - - sub_course_id - - title - - video_url - type: object - handlers.createVideoFromVimeoIDReq: - properties: - description: - type: string - display_order: - type: integer - instructor_id: - type: string - sub_course_id: - type: integer - title: - type: string - vimeo_video_id: - type: string - required: - - sub_course_id - - title - - vimeo_video_id - type: object - handlers.createVimeoVideoReq: - properties: - description: - type: string - display_order: - type: integer - duration: - type: integer - file_size: - type: integer - instructor_id: - type: string - resolution: - type: string - source_url: - type: string - sub_course_id: - type: integer - thumbnail: - type: string - title: - type: string - visibility: - type: string - required: - - file_size - - source_url - - sub_course_id - - title type: object handlers.initiateDirectPaymentReq: properties: @@ -1293,24 +1273,10 @@ definitions: - access_token - refresh_token type: object - handlers.reorderItem: + handlers.resetAndReseedReq: properties: - id: - type: integer - position: - type: integer - required: - - id - type: object - handlers.reorderReq: - properties: - items: - items: - $ref: '#/definitions/handlers.reorderItem' - minItems: 1 - type: array - required: - - items + confirm: + type: string type: object handlers.shortAnswerInput: properties: @@ -1388,26 +1354,6 @@ definitions: example: false type: boolean type: object - handlers.updateCourseCategoryReq: - properties: - is_active: - type: boolean - name: - type: string - type: object - handlers.updateCourseReq: - properties: - description: - type: string - intro_video_url: - type: string - is_active: - type: boolean - thumbnail: - type: string - title: - type: string - type: object handlers.updateIssueStatusReq: properties: status: @@ -1437,15 +1383,6 @@ definitions: price: type: number type: object - handlers.updateProgressReq: - properties: - progress_percentage: - maximum: 100 - minimum: 0 - type: integer - required: - - progress_percentage - type: object handlers.updateQuestionOrderReq: properties: display_order: @@ -1492,6 +1429,8 @@ definitions: type: string description: type: string + intro_video_url: + type: string passing_score: type: integer persona: @@ -1507,45 +1446,6 @@ definitions: title: type: string type: object - handlers.updateSubCourseReq: - properties: - description: - type: string - display_order: - type: integer - is_active: - type: boolean - level: - type: string - sub_level: - type: string - thumbnail: - type: string - title: - type: string - type: object - handlers.updateSubCourseVideoReq: - properties: - description: - type: string - display_order: - type: integer - duration: - type: integer - resolution: - type: string - status: - description: DRAFT, PUBLISHED, INACTIVE, ARCHIVED - type: string - thumbnail: - type: string - title: - type: string - video_url: - type: string - visibility: - type: string - type: object handlers.verifyOTPReq: properties: otp: @@ -2185,71 +2085,6 @@ paths: summary: Update Admin tags: - admin - /api/v1/admin/users/{userId}/progress/courses/{courseId}: - get: - description: Returns a target learner's progress for all sub-courses in a course, - including lock status - parameters: - - description: Learner User ID - in: path - name: userId - required: true - type: integer - - description: Course ID - in: path - name: courseId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get learner's course progress (admin) - tags: - - progression - /api/v1/admin/users/{userId}/progress/courses/{courseId}/summary: - get: - description: Returns course-level aggregated progress metrics for a target learner - parameters: - - description: Learner User ID - in: path - name: userId - required: true - type: integer - - description: Course ID - in: path - name: courseId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get learner's course progress summary (admin) - tags: - - progression /api/v1/admin/users/deletion-requests: get: consumes: @@ -2488,292 +2323,9 @@ paths: summary: Refresh token tags: - auth - /api/v1/course-management/categories: + /api/v1/course-management/courses/{courseId}/hierarchy: get: - description: Returns a paginated list of all course categories - parameters: - - default: 10 - description: Limit - in: query - name: limit - type: integer - - default: 0 - description: Offset - in: query - name: offset - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get all course categories - tags: - - course-categories - post: - consumes: - - application/json - description: Creates a new course category with the provided name - parameters: - - description: Create category payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.createCourseCategoryReq' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Create a new course category - tags: - - course-categories - /api/v1/course-management/categories/{categoryId}/courses: - get: - description: Returns a paginated list of courses under a specific category - parameters: - - description: Category ID - in: path - name: categoryId - required: true - type: integer - - default: 10 - description: Limit - in: query - name: limit - type: integer - - default: 0 - description: Offset - in: query - name: offset - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get courses by category - tags: - - courses - /api/v1/course-management/categories/{id}: - delete: - description: Deletes a course category by its ID - parameters: - - description: Category ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Delete course category - tags: - - course-categories - get: - description: Returns a single course category by its ID - parameters: - - description: Category ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get course category by ID - tags: - - course-categories - put: - consumes: - - application/json - description: Updates a course category's name and/or active status - parameters: - - description: Category ID - in: path - name: id - required: true - type: integer - - description: Update category payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.updateCourseCategoryReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Update course category - tags: - - course-categories - /api/v1/course-management/categories/reorder: - put: - consumes: - - application/json - description: Updates the display_order of course categories for drag-and-drop - sorting - parameters: - - description: Reorder payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.reorderReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Reorder course categories - tags: - - course-categories - /api/v1/course-management/courses: - post: - consumes: - - application/json - description: Creates a new course under a specific category - parameters: - - description: Create course payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.createCourseReq' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Create a new course - tags: - - courses - /api/v1/course-management/courses/{courseId}/learning-path: - get: - description: |- - Returns the complete learning path for a course including sub-courses (by level), - video lessons, practices, prerequisites, and counts — structured for admin panel flexible path configuration - parameters: - - description: Course ID - in: path - name: courseId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get course learning path - tags: - - learning-tree - /api/v1/course-management/courses/{courseId}/sub-courses: - get: - description: Returns all sub-courses under a specific course + description: Returns hierarchy nodes for one course including levels/modules/sub-modules parameters: - description: Course ID in: path @@ -2795,18 +2347,12 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Get sub-courses by course + summary: Get hierarchy for a course tags: - - sub-courses - /api/v1/course-management/courses/{courseId}/sub-courses/list: + - course-management + /api/v1/course-management/hierarchy: get: - description: Returns a list of active sub-courses under a specific course - parameters: - - description: Course ID - in: path - name: courseId - required: true - type: integer + description: 'Returns full hierarchy: category -> sub-category -> course' produces: - application/json responses: @@ -2814,95 +2360,30 @@ paths: description: OK schema: $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' "500": description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: List active sub-courses by course + summary: Get unified course hierarchy tags: - - sub-courses - /api/v1/course-management/courses/{id}: - delete: - description: Deletes a course by its ID - parameters: - - description: Course ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Delete course - tags: - - courses - get: - description: Returns a single course by its ID - parameters: - - description: Course ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get course by ID - tags: - - courses - put: + - course-management + /api/v1/course-management/levels: + post: consumes: - application/json - description: Updates a course's title, description, and/or active status + description: Creates a CEFR level under a course parameters: - - description: Course ID - in: path - name: id - required: true - type: integer - - description: Update course payload + - description: Create level payload in: body name: body required: true schema: - $ref: '#/definitions/handlers.updateCourseReq' + $ref: '#/definitions/handlers.createLevelReq' produces: - application/json responses: - "200": - description: OK + "201": + description: Created schema: $ref: '#/definitions/domain.Response' "400": @@ -2913,536 +2394,264 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Update course + summary: Create level tags: - - courses - /api/v1/course-management/courses/{id}/thumbnail: + - course-management + /api/v1/course-management/modules: + post: + consumes: + - application/json + description: Creates a module under a level + parameters: + - description: Create module payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createModuleReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create module + tags: + - course-management + /api/v1/course-management/sub-categories: + post: + consumes: + - application/json + description: Creates a sub-category under a course category + parameters: + - description: Create sub-category payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createCourseSubCategoryReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create course sub-category + tags: + - course-management + /api/v1/course-management/sub-module-lessons: + post: + consumes: + - application/json + description: Links a question set lesson to a sub-module + parameters: + - description: Attach lesson payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.attachSubModuleLessonReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Attach lesson to sub-module + tags: + - course-management + /api/v1/course-management/sub-module-practices: + post: + consumes: + - application/json + description: Creates a sub-module practice with metadata and linked question + set + parameters: + - description: Create practice payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createSubModulePracticeReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create practice under sub-module + tags: + - course-management + /api/v1/course-management/sub-module-videos: + post: + consumes: + - application/json + description: Creates a video under a sub-module + parameters: + - description: Create sub-module video payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createSubModuleVideoReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create sub-module video + tags: + - course-management + /api/v1/course-management/sub-modules: + post: + consumes: + - application/json + description: Creates a sub-module under a module + parameters: + - description: Create sub-module payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.createSubModuleReq' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/domain.Response' + "400": + description: Bad Request + schema: + $ref: '#/definitions/domain.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/domain.ErrorResponse' + summary: Create sub-module + tags: + - course-management + /api/v1/files/audio: post: consumes: - multipart/form-data - description: Uploads and optimizes a thumbnail image, then updates the course parameters: - - description: Course ID - in: path - name: id - required: true - type: integer - - description: Thumbnail image file (jpg, png, webp) + - description: Audio file (mp3, wav, ogg, m4a, aac, webm) in: formData name: file required: true type: file - produces: - - application/json responses: "200": description: OK schema: $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Upload a thumbnail image for a course + summary: Upload an audio file tags: - - courses - /api/v1/course-management/courses/reorder: - put: - consumes: - - application/json - description: Updates the display_order of courses for drag-and-drop sorting - parameters: - - description: Reorder payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.reorderReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Reorder courses within a category - tags: - - courses - /api/v1/course-management/learning-tree: - get: - description: Returns the complete learning tree structure with courses and sub-courses - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get full learning tree - tags: - - learning-tree - /api/v1/course-management/practices/reorder: - put: - consumes: - - application/json - description: Updates the display_order of practices for drag-and-drop sorting - parameters: - - description: Reorder payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.reorderReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Reorder practices (question sets) within a sub-course - tags: - - question-sets - /api/v1/course-management/sub-courses: - post: - consumes: - - application/json - description: Creates a new sub-course under a specific course - parameters: - - description: Create sub-course payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.createSubCourseReq' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Create a new sub-course - tags: - - sub-courses - /api/v1/course-management/sub-courses/{id}: - delete: - description: Deletes a sub-course by its ID - parameters: - - description: Sub-course ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Delete sub-course - tags: - - sub-courses - get: - description: Returns a single sub-course by its ID - parameters: - - description: Sub-course ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get sub-course by ID - tags: - - sub-courses - patch: - consumes: - - application/json - description: Updates a sub-course's fields - parameters: - - description: Sub-course ID - in: path - name: id - required: true - type: integer - - description: Update sub-course payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.updateSubCourseReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Update sub-course - tags: - - sub-courses - /api/v1/course-management/sub-courses/{id}/deactivate: - put: - description: Deactivates a sub-course by its ID - parameters: - - description: Sub-course ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Deactivate sub-course - tags: - - sub-courses - /api/v1/course-management/sub-courses/{id}/prerequisites: - get: - description: Returns all prerequisites for a sub-course - parameters: - - description: Sub-course ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get sub-course prerequisites - tags: - - progression - post: - consumes: - - application/json - description: Link a prerequisite sub-course that must be completed before accessing - this sub-course - parameters: - - description: Sub-course ID - in: path - name: id - required: true - type: integer - - description: Prerequisite sub-course ID - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.addPrerequisiteReq' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Add prerequisite to sub-course - tags: - - progression - /api/v1/course-management/sub-courses/{id}/prerequisites/{prerequisiteId}: - delete: - description: Unlink a prerequisite from a sub-course - parameters: - - description: Sub-course ID - in: path - name: id - required: true - type: integer - - description: Prerequisite sub-course ID - in: path - name: prerequisiteId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Remove prerequisite from sub-course - tags: - - progression - /api/v1/course-management/sub-courses/{id}/thumbnail: + - files + /api/v1/files/upload: post: consumes: - multipart/form-data - description: Uploads and optimizes a thumbnail image, then updates the sub-course parameters: - - description: Sub-course ID - in: path - name: id + - description: 'Media type: image|audio|video' + in: formData + name: media_type required: true - type: integer - - description: Thumbnail image file (jpg, png, webp) + type: string + - description: Media file in: formData name: file required: true type: file - produces: - - application/json responses: "200": description: OK schema: $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Upload a thumbnail image for a sub-course + summary: Upload media file tags: - - sub-courses - /api/v1/course-management/sub-courses/{subCourseId}/videos: + - files + /api/v1/files/url: get: - description: Returns all videos under a specific sub-course parameters: - - description: Sub-course ID - in: path - name: subCourseId + - description: MinIO object key (e.g. profile_pictures/uuid.jpg) + in: query + name: key required: true - type: integer - produces: - - application/json + type: string responses: "200": description: OK schema: $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get videos by sub-course + summary: Get presigned URL for a file tags: - - sub-course-videos - /api/v1/course-management/sub-courses/{subCourseId}/videos/published: - get: - description: Returns all published videos under a specific sub-course - parameters: - - description: Sub-course ID - in: path - name: subCourseId - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get published videos by sub-course - tags: - - sub-course-videos - /api/v1/course-management/sub-courses/active: - get: - description: Returns a list of all active sub-courses - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: List all active sub-courses - tags: - - sub-courses - /api/v1/course-management/sub-courses/reorder: - put: - consumes: - - application/json - description: Updates the display_order of sub-courses for drag-and-drop sorting - parameters: - - description: Reorder payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.reorderReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Reorder sub-courses within a course - tags: - - sub-courses - /api/v1/course-management/videos: + - files + /api/v1/internal/db/clear-course-management: post: consumes: - application/json - description: Creates a new video under a specific sub-course + description: Truncates course_categories, courses, and sub_courses (same scope + as reset-reseed) without re-inserting seed SQL. parameters: - - description: Create video payload + - description: Optional token when DB_RESET_RESEED_TOKEN is set + in: header + name: X-Seed-Reset-Token + type: string + - description: Confirmation payload in: body name: body required: true schema: - $ref: '#/definitions/handlers.createSubCourseVideoReq' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Create a new sub-course video - tags: - - sub-course-videos - /api/v1/course-management/videos/{id}: - delete: - description: Archives a video by its ID (soft delete) - parameters: - - description: Video ID - in: path - name: id - required: true - type: integer + $ref: '#/definitions/handlers.clearCourseManagementReq' produces: - application/json responses: @@ -3454,258 +2663,57 @@ paths: description: Bad Request schema: $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Delete sub-course video - tags: - - sub-course-videos - get: - description: Returns a single video by its ID - parameters: - - description: Video ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Get sub-course video by ID - tags: - - sub-course-videos - put: - consumes: - - application/json - description: Updates a video's fields - parameters: - - description: Video ID - in: path - name: id - required: true - type: integer - - description: Update video payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.updateSubCourseVideoReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request + "403": + description: Forbidden schema: $ref: '#/definitions/domain.ErrorResponse' "500": description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Update sub-course video + summary: Clear course management hierarchy data only tags: - - sub-course-videos - /api/v1/course-management/videos/{id}/publish: - put: - description: Publishes a video by its ID - parameters: - - description: Video ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Publish sub-course video - tags: - - sub-course-videos - /api/v1/course-management/videos/reorder: - put: - consumes: - - application/json - description: Updates the display_order of videos for drag-and-drop sorting - parameters: - - description: Reorder payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.reorderReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Reorder videos within a sub-course - tags: - - sub-course-videos - /api/v1/course-management/videos/upload: - post: - consumes: - - multipart/form-data - description: Accepts a video file upload, uploads it to Vimeo via TUS, and creates - a sub-course video record - parameters: - - description: Video file - in: formData - name: file - required: true - type: file - - description: Sub-course ID - in: formData - name: sub_course_id - required: true - type: integer - - description: Video title - in: formData - name: title - required: true - type: string - - description: Video description - in: formData - name: description - type: string - - description: Duration in seconds - in: formData - name: duration - type: integer - - description: Video resolution - in: formData - name: resolution - type: string - - description: Instructor ID - in: formData - name: instructor_id - type: string - - description: Thumbnail URL - in: formData - name: thumbnail - type: string - - description: Visibility - in: formData - name: visibility - type: string - - description: Display order - in: formData - name: display_order - type: integer - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Upload a video file and create sub-course video - tags: - - sub-course-videos - /api/v1/course-management/videos/vimeo: + - internal + /api/v1/internal/db/reset-reseed: post: consumes: - application/json - description: Creates a video by uploading to Vimeo from a source URL + description: 'Dangerous operation: clears and reseeds only course_categories, + courses, and sub_courses from seed SQL files.' parameters: - - description: Create Vimeo video payload + - description: Reset token configured in DB_RESET_RESEED_TOKEN + in: header + name: X-Seed-Reset-Token + required: true + type: string + - description: Confirmation payload in: body name: body required: true schema: - $ref: '#/definitions/handlers.createVimeoVideoReq' + $ref: '#/definitions/handlers.resetAndReseedReq' produces: - application/json responses: - "201": - description: Created + "200": + description: OK schema: $ref: '#/definitions/domain.Response' "400": description: Bad Request schema: $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Create a new sub-course video with Vimeo upload - tags: - - sub-course-videos - /api/v1/course-management/videos/vimeo/import: - post: - consumes: - - application/json - description: Creates a video record from an existing Vimeo video ID - parameters: - - description: Create from Vimeo ID payload - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.createVideoFromVimeoIDReq' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request + "403": + description: Forbidden schema: $ref: '#/definitions/domain.ErrorResponse' "500": description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Create a sub-course video from existing Vimeo video + summary: Reset and reseed database tags: - - sub-course-videos + - internal /api/v1/internal/users/purge-due-deletions: post: description: Worker-safe purge for due self-deletion requests @@ -4554,16 +3562,30 @@ paths: summary: Handle ArifPay webhook tags: - payments - /api/v1/progress/courses/{courseId}: + /api/v1/practices/{practiceId}/questions: get: - description: Returns the authenticated user's progress for all sub-courses in - a course, including lock status + description: Returns paginated questions for a practice(question-set), including + AUDIO fields parameters: - - description: Course ID + - description: Practice(question-set) ID in: path - name: courseId + name: practiceId required: true type: integer + - description: Question type filter (e.g. AUDIO) + in: query + name: question_type + type: string + - default: 10 + description: Limit + in: query + name: limit + type: integer + - default: 0 + description: Offset + in: query + name: offset + type: integer produces: - application/json responses: @@ -4575,13 +3597,17 @@ paths: description: Bad Request schema: $ref: '#/definitions/domain.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/domain.ErrorResponse' "500": description: Internal Server Error schema: $ref: '#/definitions/domain.ErrorResponse' - summary: Get user's course progress + summary: Get questions by practice tags: - - progression + - question-set-items /api/v1/progress/practices/{id}/complete: post: description: Marks a practice question set as completed for the authenticated @@ -4618,163 +3644,6 @@ paths: summary: Mark practice as completed tags: - progression - /api/v1/progress/sub-courses/{id}: - put: - consumes: - - application/json - description: Update the progress percentage for a sub-course - parameters: - - description: Sub-course ID - in: path - name: id - required: true - type: integer - - description: Progress update - in: body - name: body - required: true - schema: - $ref: '#/definitions/handlers.updateProgressReq' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Update sub-course progress - tags: - - progression - /api/v1/progress/sub-courses/{id}/access: - get: - description: Check if the authenticated user has completed all prerequisites - for a sub-course - parameters: - - description: Sub-course ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Check sub-course access - tags: - - progression - /api/v1/progress/sub-courses/{id}/complete: - post: - description: Mark a sub-course as completed for the authenticated user - parameters: - - description: Sub-course ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Complete a sub-course - tags: - - progression - /api/v1/progress/sub-courses/{id}/start: - post: - description: Mark a sub-course as started for the authenticated user (checks - prerequisites) - parameters: - - description: Sub-course ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "403": - description: Forbidden - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Start a sub-course - tags: - - progression - /api/v1/progress/videos/{id}/complete: - post: - description: Marks the given video as completed for the authenticated learner - parameters: - - description: Video ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/domain.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/domain.ErrorResponse' - "403": - description: Forbidden - schema: - $ref: '#/definitions/domain.ErrorResponse' - "404": - description: Not Found - schema: - $ref: '#/definitions/domain.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/domain.ErrorResponse' - summary: Mark sub-course video as completed - tags: - - progression /api/v1/question-sets: get: description: Returns a paginated list of question sets filtered by type @@ -5380,6 +4249,34 @@ paths: summary: Update a question tags: - questions + /api/v1/questions/audio-answer: + post: + consumes: + - multipart/form-data + parameters: + - description: Question ID + in: formData + name: question_id + required: true + type: integer + - description: Question Set ID + in: formData + name: question_set_id + required: true + type: integer + - description: Audio recording + in: formData + name: file + required: true + type: file + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.Response' + summary: Submit audio answer for a question + tags: + - questions /api/v1/questions/search: get: description: Search questions by text diff --git a/gen/db/courses.sql.go b/gen/db/courses.sql.go index 453c079..f3347cc 100644 --- a/gen/db/courses.sql.go +++ b/gen/db/courses.sql.go @@ -21,7 +21,7 @@ INSERT INTO courses ( is_active ) VALUES ($1, $2, $3, $4, $5, COALESCE($6, true)) -RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order +RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id ` type CreateCourseParams struct { @@ -52,6 +52,7 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou &i.Thumbnail, &i.IntroVideoUrl, &i.DisplayOrder, + &i.SubCategoryID, ) return i, err } @@ -67,7 +68,7 @@ func (q *Queries) DeleteCourse(ctx context.Context, id int64) error { } const GetCourseByID = `-- name: GetCourseByID :one -SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order +SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id FROM courses WHERE id = $1 ` @@ -84,6 +85,7 @@ func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) { &i.Thumbnail, &i.IntroVideoUrl, &i.DisplayOrder, + &i.SubCategoryID, ) return i, err } diff --git a/gen/db/hierarchy.sql.go b/gen/db/hierarchy.sql.go new file mode 100644 index 0000000..069f657 --- /dev/null +++ b/gen/db/hierarchy.sql.go @@ -0,0 +1,766 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: hierarchy.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const AttachQuestionSetLessonToSubModule = `-- name: AttachQuestionSetLessonToSubModule :one +INSERT INTO sub_module_lessons ( + sub_module_id, + question_set_id, + intro_video_url, + display_order, + is_active +) +VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE)) +RETURNING id, sub_module_id, question_set_id, intro_video_url, display_order, is_active, created_at +` + +type AttachQuestionSetLessonToSubModuleParams struct { + SubModuleID int64 `json:"sub_module_id"` + QuestionSetID int64 `json:"question_set_id"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + Column4 interface{} `json:"column_4"` + Column5 interface{} `json:"column_5"` +} + +func (q *Queries) AttachQuestionSetLessonToSubModule(ctx context.Context, arg AttachQuestionSetLessonToSubModuleParams) (SubModuleLesson, error) { + row := q.db.QueryRow(ctx, AttachQuestionSetLessonToSubModule, + arg.SubModuleID, + arg.QuestionSetID, + arg.IntroVideoUrl, + arg.Column4, + arg.Column5, + ) + var i SubModuleLesson + err := row.Scan( + &i.ID, + &i.SubModuleID, + &i.QuestionSetID, + &i.IntroVideoUrl, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + ) + return i, err +} + +const CreateCourseSubCategory = `-- name: CreateCourseSubCategory :one +INSERT INTO course_sub_categories ( + category_id, + name, + description, + display_order, + is_active +) +VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE)) +RETURNING id, category_id, name, description, is_active, display_order, created_at +` + +type CreateCourseSubCategoryParams struct { + CategoryID int64 `json:"category_id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Column4 interface{} `json:"column_4"` + Column5 interface{} `json:"column_5"` +} + +func (q *Queries) CreateCourseSubCategory(ctx context.Context, arg CreateCourseSubCategoryParams) (CourseSubCategory, error) { + row := q.db.QueryRow(ctx, CreateCourseSubCategory, + arg.CategoryID, + arg.Name, + arg.Description, + arg.Column4, + arg.Column5, + ) + var i CourseSubCategory + err := row.Scan( + &i.ID, + &i.CategoryID, + &i.Name, + &i.Description, + &i.IsActive, + &i.DisplayOrder, + &i.CreatedAt, + ) + return i, err +} + +const CreateLevel = `-- name: CreateLevel :one +INSERT INTO levels ( + course_id, + cefr_level, + display_order, + is_active +) +VALUES ($1, $2, COALESCE($3, 0), COALESCE($4, TRUE)) +RETURNING id, course_id, cefr_level, display_order, is_active, created_at +` + +type CreateLevelParams struct { + CourseID int64 `json:"course_id"` + CefrLevel string `json:"cefr_level"` + Column3 interface{} `json:"column_3"` + Column4 interface{} `json:"column_4"` +} + +func (q *Queries) CreateLevel(ctx context.Context, arg CreateLevelParams) (Level, error) { + row := q.db.QueryRow(ctx, CreateLevel, + arg.CourseID, + arg.CefrLevel, + arg.Column3, + arg.Column4, + ) + var i Level + err := row.Scan( + &i.ID, + &i.CourseID, + &i.CefrLevel, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + ) + return i, err +} + +const CreateModule = `-- name: CreateModule :one +INSERT INTO modules ( + level_id, + title, + description, + display_order, + is_active +) +VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE)) +RETURNING id, level_id, title, description, display_order, is_active, created_at +` + +type CreateModuleParams struct { + LevelID int64 `json:"level_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Column4 interface{} `json:"column_4"` + Column5 interface{} `json:"column_5"` +} + +func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) { + row := q.db.QueryRow(ctx, CreateModule, + arg.LevelID, + arg.Title, + arg.Description, + arg.Column4, + arg.Column5, + ) + var i Module + err := row.Scan( + &i.ID, + &i.LevelID, + &i.Title, + &i.Description, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + ) + return i, err +} + +const CreateSubModule = `-- name: CreateSubModule :one +INSERT INTO sub_modules ( + module_id, + title, + description, + display_order, + is_active +) +VALUES ($1, $2, $3, COALESCE($4, 0), COALESCE($5, TRUE)) +RETURNING id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id +` + +type CreateSubModuleParams struct { + ModuleID int64 `json:"module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Column4 interface{} `json:"column_4"` + Column5 interface{} `json:"column_5"` +} + +func (q *Queries) CreateSubModule(ctx context.Context, arg CreateSubModuleParams) (SubModule, error) { + row := q.db.QueryRow(ctx, CreateSubModule, + arg.ModuleID, + arg.Title, + arg.Description, + arg.Column4, + arg.Column5, + ) + var i SubModule + err := row.Scan( + &i.ID, + &i.ModuleID, + &i.Title, + &i.Description, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + &i.LegacySubCourseID, + ) + return i, err +} + +const CreateSubModulePractice = `-- name: CreateSubModulePractice :one +INSERT INTO sub_module_practices ( + sub_module_id, + title, + description, + thumbnail, + intro_video_url, + question_set_id, + display_order, + is_active +) +VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE)) +RETURNING id, sub_module_id, title, description, thumbnail, intro_video_url, question_set_id, display_order, is_active, created_at +` + +type CreateSubModulePracticeParams struct { + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + QuestionSetID int64 `json:"question_set_id"` + Column7 interface{} `json:"column_7"` + Column8 interface{} `json:"column_8"` +} + +func (q *Queries) CreateSubModulePractice(ctx context.Context, arg CreateSubModulePracticeParams) (SubModulePractice, error) { + row := q.db.QueryRow(ctx, CreateSubModulePractice, + arg.SubModuleID, + arg.Title, + arg.Description, + arg.Thumbnail, + arg.IntroVideoUrl, + arg.QuestionSetID, + arg.Column7, + arg.Column8, + ) + var i SubModulePractice + err := row.Scan( + &i.ID, + &i.SubModuleID, + &i.Title, + &i.Description, + &i.Thumbnail, + &i.IntroVideoUrl, + &i.QuestionSetID, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + ) + return i, err +} + +const CreateSubModuleVideo = `-- name: CreateSubModuleVideo :one +INSERT INTO sub_module_videos ( + sub_module_id, + title, + description, + video_url, + duration, + resolution, + is_published, + publish_date, + visibility, + instructor_id, + thumbnail, + display_order, + status, + vimeo_id, + vimeo_embed_url, + vimeo_player_html, + vimeo_status, + video_host_provider +) +VALUES ( + $1, $2, $3, $4, $5, $6, + COALESCE($7, FALSE), $8, $9, $10, $11, + COALESCE($12, 0), COALESCE($13, 'DRAFT'), + $14, $15, $16, $17, COALESCE($18, 'DIRECT') +) +RETURNING id, sub_module_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider, created_at +` + +type CreateSubModuleVideoParams struct { + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + VideoUrl string `json:"video_url"` + Duration pgtype.Int4 `json:"duration"` + Resolution pgtype.Text `json:"resolution"` + Column7 interface{} `json:"column_7"` + PublishDate pgtype.Timestamptz `json:"publish_date"` + Visibility pgtype.Text `json:"visibility"` + InstructorID pgtype.Text `json:"instructor_id"` + Thumbnail pgtype.Text `json:"thumbnail"` + Column12 interface{} `json:"column_12"` + Column13 interface{} `json:"column_13"` + VimeoID pgtype.Text `json:"vimeo_id"` + VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"` + VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"` + VimeoStatus pgtype.Text `json:"vimeo_status"` + Column18 interface{} `json:"column_18"` +} + +func (q *Queries) CreateSubModuleVideo(ctx context.Context, arg CreateSubModuleVideoParams) (SubModuleVideo, error) { + row := q.db.QueryRow(ctx, CreateSubModuleVideo, + arg.SubModuleID, + arg.Title, + arg.Description, + arg.VideoUrl, + arg.Duration, + arg.Resolution, + arg.Column7, + arg.PublishDate, + arg.Visibility, + arg.InstructorID, + arg.Thumbnail, + arg.Column12, + arg.Column13, + arg.VimeoID, + arg.VimeoEmbedUrl, + arg.VimeoPlayerHtml, + arg.VimeoStatus, + arg.Column18, + ) + var i SubModuleVideo + err := row.Scan( + &i.ID, + &i.SubModuleID, + &i.Title, + &i.Description, + &i.VideoUrl, + &i.Duration, + &i.Resolution, + &i.IsPublished, + &i.PublishDate, + &i.Visibility, + &i.InstructorID, + &i.Thumbnail, + &i.DisplayOrder, + &i.Status, + &i.VimeoID, + &i.VimeoEmbedUrl, + &i.VimeoPlayerHtml, + &i.VimeoStatus, + &i.VideoHostProvider, + &i.CreatedAt, + ) + return i, err +} + +const GetCoursesWithHierarchy = `-- name: GetCoursesWithHierarchy :many +SELECT + cc.id AS category_id, + cc.name AS category_name, + csc.id AS sub_category_id, + csc.name AS sub_category_name, + c.id AS course_id, + c.title AS course_title +FROM course_categories cc +LEFT JOIN course_sub_categories csc ON csc.category_id = cc.id AND csc.is_active = TRUE +LEFT JOIN courses c ON c.sub_category_id = csc.id AND c.is_active = TRUE +WHERE cc.is_active = TRUE +ORDER BY cc.id, csc.display_order, csc.id, c.id +` + +type GetCoursesWithHierarchyRow struct { + CategoryID int64 `json:"category_id"` + CategoryName string `json:"category_name"` + SubCategoryID pgtype.Int8 `json:"sub_category_id"` + SubCategoryName pgtype.Text `json:"sub_category_name"` + CourseID pgtype.Int8 `json:"course_id"` + CourseTitle pgtype.Text `json:"course_title"` +} + +func (q *Queries) GetCoursesWithHierarchy(ctx context.Context) ([]GetCoursesWithHierarchyRow, error) { + rows, err := q.db.Query(ctx, GetCoursesWithHierarchy) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetCoursesWithHierarchyRow + for rows.Next() { + var i GetCoursesWithHierarchyRow + if err := rows.Scan( + &i.CategoryID, + &i.CategoryName, + &i.SubCategoryID, + &i.SubCategoryName, + &i.CourseID, + &i.CourseTitle, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetFullHierarchyByCourseID = `-- name: GetFullHierarchyByCourseID :many +SELECT + c.id AS course_id, + c.title AS course_title, + l.id AS level_id, + l.cefr_level, + m.id AS module_id, + m.title AS module_title, + sm.id AS sub_module_id, + sm.title AS sub_module_title +FROM courses c +LEFT JOIN levels l ON l.course_id = c.id AND l.is_active = TRUE +LEFT JOIN modules m ON m.level_id = l.id AND m.is_active = TRUE +LEFT JOIN sub_modules sm ON sm.module_id = m.id AND sm.is_active = TRUE +WHERE c.id = $1 +ORDER BY l.display_order, l.id, m.display_order, m.id, sm.display_order, sm.id +` + +type GetFullHierarchyByCourseIDRow struct { + CourseID int64 `json:"course_id"` + CourseTitle string `json:"course_title"` + LevelID pgtype.Int8 `json:"level_id"` + CefrLevel pgtype.Text `json:"cefr_level"` + ModuleID pgtype.Int8 `json:"module_id"` + ModuleTitle pgtype.Text `json:"module_title"` + SubModuleID pgtype.Int8 `json:"sub_module_id"` + SubModuleTitle pgtype.Text `json:"sub_module_title"` +} + +func (q *Queries) GetFullHierarchyByCourseID(ctx context.Context, id int64) ([]GetFullHierarchyByCourseIDRow, error) { + rows, err := q.db.Query(ctx, GetFullHierarchyByCourseID, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetFullHierarchyByCourseIDRow + for rows.Next() { + var i GetFullHierarchyByCourseIDRow + if err := rows.Scan( + &i.CourseID, + &i.CourseTitle, + &i.LevelID, + &i.CefrLevel, + &i.ModuleID, + &i.ModuleTitle, + &i.SubModuleID, + &i.SubModuleTitle, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetLevelsByCourseID = `-- name: GetLevelsByCourseID :many +SELECT id, course_id, cefr_level, display_order, is_active, created_at +FROM levels +WHERE course_id = $1 + AND is_active = TRUE +ORDER BY display_order ASC, id ASC +` + +func (q *Queries) GetLevelsByCourseID(ctx context.Context, courseID int64) ([]Level, error) { + rows, err := q.db.Query(ctx, GetLevelsByCourseID, courseID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Level + for rows.Next() { + var i Level + if err := rows.Scan( + &i.ID, + &i.CourseID, + &i.CefrLevel, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetModulesByLevelID = `-- name: GetModulesByLevelID :many +SELECT id, level_id, title, description, display_order, is_active, created_at +FROM modules +WHERE level_id = $1 + AND is_active = TRUE +ORDER BY display_order ASC, id ASC +` + +func (q *Queries) GetModulesByLevelID(ctx context.Context, levelID int64) ([]Module, error) { + rows, err := q.db.Query(ctx, GetModulesByLevelID, levelID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Module + for rows.Next() { + var i Module + if err := rows.Scan( + &i.ID, + &i.LevelID, + &i.Title, + &i.Description, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetSubModuleLessons = `-- name: GetSubModuleLessons :many +SELECT + smp.id, + smp.sub_module_id, + smp.question_set_id, + smp.intro_video_url, + smp.display_order, + smp.is_active, + qs.title, + qs.description, + qs.status, + qs.set_type, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM sub_module_lessons smp +JOIN question_sets qs ON qs.id = smp.question_set_id +WHERE smp.sub_module_id = $1 + AND smp.is_active = TRUE +ORDER BY smp.display_order ASC, smp.id ASC +` + +type GetSubModuleLessonsRow struct { + ID int64 `json:"id"` + SubModuleID int64 `json:"sub_module_id"` + QuestionSetID int64 `json:"question_set_id"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Status string `json:"status"` + SetType string `json:"set_type"` + QuestionCount int64 `json:"question_count"` +} + +func (q *Queries) GetSubModuleLessons(ctx context.Context, subModuleID int64) ([]GetSubModuleLessonsRow, error) { + rows, err := q.db.Query(ctx, GetSubModuleLessons, subModuleID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSubModuleLessonsRow + for rows.Next() { + var i GetSubModuleLessonsRow + if err := rows.Scan( + &i.ID, + &i.SubModuleID, + &i.QuestionSetID, + &i.IntroVideoUrl, + &i.DisplayOrder, + &i.IsActive, + &i.Title, + &i.Description, + &i.Status, + &i.SetType, + &i.QuestionCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetSubModulePractices = `-- name: GetSubModulePractices :many +SELECT + smp.id, + smp.sub_module_id, + smp.title, + smp.description, + smp.thumbnail, + smp.intro_video_url, + smp.question_set_id, + smp.display_order, + smp.is_active, + qs.status, + qs.set_type, + (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count +FROM sub_module_practices smp +JOIN question_sets qs ON qs.id = smp.question_set_id +WHERE smp.sub_module_id = $1 + AND smp.is_active = TRUE +ORDER BY smp.display_order ASC, smp.id ASC +` + +type GetSubModulePracticesRow struct { + ID int64 `json:"id"` + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + QuestionSetID int64 `json:"question_set_id"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + Status string `json:"status"` + SetType string `json:"set_type"` + QuestionCount int64 `json:"question_count"` +} + +func (q *Queries) GetSubModulePractices(ctx context.Context, subModuleID int64) ([]GetSubModulePracticesRow, error) { + rows, err := q.db.Query(ctx, GetSubModulePractices, subModuleID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSubModulePracticesRow + for rows.Next() { + var i GetSubModulePracticesRow + if err := rows.Scan( + &i.ID, + &i.SubModuleID, + &i.Title, + &i.Description, + &i.Thumbnail, + &i.IntroVideoUrl, + &i.QuestionSetID, + &i.DisplayOrder, + &i.IsActive, + &i.Status, + &i.SetType, + &i.QuestionCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetSubModuleVideos = `-- name: GetSubModuleVideos :many +SELECT id, sub_module_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider, created_at +FROM sub_module_videos +WHERE sub_module_id = $1 + AND status != 'ARCHIVED' +ORDER BY display_order ASC, id ASC +` + +func (q *Queries) GetSubModuleVideos(ctx context.Context, subModuleID int64) ([]SubModuleVideo, error) { + rows, err := q.db.Query(ctx, GetSubModuleVideos, subModuleID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SubModuleVideo + for rows.Next() { + var i SubModuleVideo + if err := rows.Scan( + &i.ID, + &i.SubModuleID, + &i.Title, + &i.Description, + &i.VideoUrl, + &i.Duration, + &i.Resolution, + &i.IsPublished, + &i.PublishDate, + &i.Visibility, + &i.InstructorID, + &i.Thumbnail, + &i.DisplayOrder, + &i.Status, + &i.VimeoID, + &i.VimeoEmbedUrl, + &i.VimeoPlayerHtml, + &i.VimeoStatus, + &i.VideoHostProvider, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetSubModulesByModuleID = `-- name: GetSubModulesByModuleID :many +SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id +FROM sub_modules +WHERE module_id = $1 + AND is_active = TRUE +ORDER BY display_order ASC, id ASC +` + +func (q *Queries) GetSubModulesByModuleID(ctx context.Context, moduleID int64) ([]SubModule, error) { + rows, err := q.db.Query(ctx, GetSubModulesByModuleID, moduleID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SubModule + for rows.Next() { + var i SubModule + if err := rows.Scan( + &i.ID, + &i.ModuleID, + &i.Title, + &i.Description, + &i.DisplayOrder, + &i.IsActive, + &i.CreatedAt, + &i.LegacySubCourseID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/gen/db/learning_tree.sql.go b/gen/db/learning_tree.sql.go deleted file mode 100644 index 96caf99..0000000 --- a/gen/db/learning_tree.sql.go +++ /dev/null @@ -1,285 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: learning_tree.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const GetCourseLearningPath = `-- name: GetCourseLearningPath :many -SELECT - c.id AS course_id, - c.title AS course_title, - c.description AS course_description, - c.thumbnail AS course_thumbnail, - c.intro_video_url AS course_intro_video_url, - cc.id AS category_id, - cc.name AS category_name, - sc.id AS sub_course_id, - sc.title AS sub_course_title, - sc.description AS sub_course_description, - 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 -FROM courses c -JOIN course_categories cc ON cc.id = c.category_id -LEFT JOIN sub_courses sc ON sc.course_id = c.id AND sc.is_active = true -WHERE c.id = $1 -ORDER BY sc.display_order, sc.id -` - -type GetCourseLearningPathRow struct { - CourseID int64 `json:"course_id"` - CourseTitle string `json:"course_title"` - CourseDescription pgtype.Text `json:"course_description"` - CourseThumbnail pgtype.Text `json:"course_thumbnail"` - CourseIntroVideoUrl pgtype.Text `json:"course_intro_video_url"` - CategoryID int64 `json:"category_id"` - CategoryName string `json:"category_name"` - SubCourseID pgtype.Int8 `json:"sub_course_id"` - SubCourseTitle pgtype.Text `json:"sub_course_title"` - SubCourseDescription pgtype.Text `json:"sub_course_description"` - 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"` -} - -func (q *Queries) GetCourseLearningPath(ctx context.Context, id int64) ([]GetCourseLearningPathRow, error) { - rows, err := q.db.Query(ctx, GetCourseLearningPath, id) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetCourseLearningPathRow - for rows.Next() { - var i GetCourseLearningPathRow - if err := rows.Scan( - &i.CourseID, - &i.CourseTitle, - &i.CourseDescription, - &i.CourseThumbnail, - &i.CourseIntroVideoUrl, - &i.CategoryID, - &i.CategoryName, - &i.SubCourseID, - &i.SubCourseTitle, - &i.SubCourseDescription, - &i.SubCourseThumbnail, - &i.SubCourseDisplayOrder, - &i.SubCourseLevel, - &i.SubCourseSubLevel, - &i.PrerequisiteCount, - &i.VideoCount, - &i.PracticeCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetFullLearningTree = `-- name: GetFullLearningTree :many -SELECT - c.id AS course_id, - c.title AS course_title, - sc.id AS sub_course_id, - sc.title AS sub_course_title, - 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 -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"` - SubCourseSubLevel pgtype.Text `json:"sub_course_sub_level"` -} - -func (q *Queries) GetFullLearningTree(ctx context.Context) ([]GetFullLearningTreeRow, error) { - rows, err := q.db.Query(ctx, GetFullLearningTree) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetFullLearningTreeRow - for rows.Next() { - var i GetFullLearningTreeRow - if err := rows.Scan( - &i.CourseID, - &i.CourseTitle, - &i.SubCourseID, - &i.SubCourseTitle, - &i.SubCourseLevel, - &i.SubCourseSubLevel, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetSubCoursePracticesForLearningPath = `-- name: GetSubCoursePracticesForLearningPath :many -SELECT id, title, description, persona, status, intro_video_url, - (SELECT COUNT(*) FROM question_set_items WHERE set_id = qs.id) AS question_count -FROM question_sets qs -WHERE qs.owner_type = 'SUB_COURSE' AND qs.owner_id = $1 - AND qs.set_type = 'PRACTICE' AND qs.status = 'PUBLISHED' -ORDER BY qs.display_order ASC, qs.created_at -` - -type GetSubCoursePracticesForLearningPathRow struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Persona pgtype.Text `json:"persona"` - Status string `json:"status"` - IntroVideoUrl pgtype.Text `json:"intro_video_url"` - QuestionCount int64 `json:"question_count"` -} - -func (q *Queries) GetSubCoursePracticesForLearningPath(ctx context.Context, ownerID pgtype.Int8) ([]GetSubCoursePracticesForLearningPathRow, error) { - rows, err := q.db.Query(ctx, GetSubCoursePracticesForLearningPath, ownerID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetSubCoursePracticesForLearningPathRow - for rows.Next() { - var i GetSubCoursePracticesForLearningPathRow - if err := rows.Scan( - &i.ID, - &i.Title, - &i.Description, - &i.Persona, - &i.Status, - &i.IntroVideoUrl, - &i.QuestionCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetSubCoursePrerequisitesForLearningPath = `-- name: GetSubCoursePrerequisitesForLearningPath :many -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 -ORDER BY sc.display_order -` - -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) { - rows, err := q.db.Query(ctx, GetSubCoursePrerequisitesForLearningPath, subCourseID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetSubCoursePrerequisitesForLearningPathRow - for rows.Next() { - var i GetSubCoursePrerequisitesForLearningPathRow - if err := rows.Scan( - &i.PrerequisiteSubCourseID, - &i.Title, - &i.Level, - &i.SubLevel, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetSubCourseVideosForLearningPath = `-- name: GetSubCourseVideosForLearningPath :many -SELECT id, title, description, video_url, duration, resolution, display_order, - vimeo_id, vimeo_embed_url, video_host_provider -FROM sub_course_videos -WHERE sub_course_id = $1 AND status = 'PUBLISHED' -ORDER BY display_order, id -` - -type GetSubCourseVideosForLearningPathRow struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - VideoUrl string `json:"video_url"` - Duration int32 `json:"duration"` - Resolution pgtype.Text `json:"resolution"` - DisplayOrder int32 `json:"display_order"` - VimeoID pgtype.Text `json:"vimeo_id"` - VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"` - VideoHostProvider pgtype.Text `json:"video_host_provider"` -} - -func (q *Queries) GetSubCourseVideosForLearningPath(ctx context.Context, subCourseID int64) ([]GetSubCourseVideosForLearningPathRow, error) { - rows, err := q.db.Query(ctx, GetSubCourseVideosForLearningPath, subCourseID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetSubCourseVideosForLearningPathRow - for rows.Next() { - var i GetSubCourseVideosForLearningPathRow - if err := rows.Scan( - &i.ID, - &i.Title, - &i.Description, - &i.VideoUrl, - &i.Duration, - &i.Resolution, - &i.DisplayOrder, - &i.VimeoID, - &i.VimeoEmbedUrl, - &i.VideoHostProvider, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/gen/db/models.go b/gen/db/models.go index 15d19be..7126a70 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -31,6 +31,7 @@ type Course struct { Thumbnail pgtype.Text `json:"thumbnail"` IntroVideoUrl pgtype.Text `json:"intro_video_url"` DisplayOrder int32 `json:"display_order"` + SubCategoryID pgtype.Int8 `json:"sub_category_id"` } type CourseCategory struct { @@ -41,6 +42,16 @@ type CourseCategory struct { DisplayOrder int32 `json:"display_order"` } +type CourseSubCategory struct { + ID int64 `json:"id"` + CategoryID int64 `json:"category_id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + IsActive bool `json:"is_active"` + DisplayOrder int32 `json:"display_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Device struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` @@ -58,11 +69,30 @@ type GlobalSetting struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type Level struct { + ID int64 `json:"id"` + CourseID int64 `json:"course_id"` + CefrLevel string `json:"cefr_level"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type LevelToSubCourse struct { LevelID int64 `json:"level_id"` SubCourseID int64 `json:"sub_course_id"` } +type Module struct { + ID int64 `json:"id"` + LevelID int64 `json:"level_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type ModuleToSubCourse struct { ModuleID int64 `json:"module_id"` SubCourseID int64 `json:"sub_course_id"` @@ -314,6 +344,63 @@ type SubCourseVideo struct { VideoHostProvider pgtype.Text `json:"video_host_provider"` } +type SubModule struct { + ID int64 `json:"id"` + ModuleID int64 `json:"module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + LegacySubCourseID pgtype.Int8 `json:"legacy_sub_course_id"` +} + +type SubModuleLesson struct { + ID int64 `json:"id"` + SubModuleID int64 `json:"sub_module_id"` + QuestionSetID int64 `json:"question_set_id"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type SubModulePractice struct { + ID int64 `json:"id"` + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + IntroVideoUrl pgtype.Text `json:"intro_video_url"` + QuestionSetID int64 `json:"question_set_id"` + DisplayOrder int32 `json:"display_order"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type SubModuleVideo struct { + ID int64 `json:"id"` + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description pgtype.Text `json:"description"` + VideoUrl string `json:"video_url"` + Duration pgtype.Int4 `json:"duration"` + Resolution pgtype.Text `json:"resolution"` + IsPublished bool `json:"is_published"` + PublishDate pgtype.Timestamptz `json:"publish_date"` + Visibility pgtype.Text `json:"visibility"` + InstructorID pgtype.Text `json:"instructor_id"` + Thumbnail pgtype.Text `json:"thumbnail"` + DisplayOrder int32 `json:"display_order"` + Status string `json:"status"` + VimeoID pgtype.Text `json:"vimeo_id"` + VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"` + VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"` + VimeoStatus pgtype.Text `json:"vimeo_status"` + VideoHostProvider pgtype.Text `json:"video_host_provider"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type SubscriptionPlan struct { ID int64 `json:"id"` Name string `json:"name"` @@ -404,7 +491,7 @@ type UserAudioResponse struct { type UserPracticeProgress struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` - SubCourseID int64 `json:"sub_course_id"` + SubCourseID pgtype.Int8 `json:"sub_course_id"` QuestionSetID int64 `json:"question_set_id"` CompletedAt pgtype.Timestamp `json:"completed_at"` CreatedAt pgtype.Timestamp `json:"created_at"` diff --git a/gen/db/practice_progress.sql.go b/gen/db/practice_progress.sql.go new file mode 100644 index 0000000..1dc8225 --- /dev/null +++ b/gen/db/practice_progress.sql.go @@ -0,0 +1,98 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: practice_progress.sql + +package dbgen + +import ( + "context" +) + +const GetFirstIncompletePreviousPractice = `-- name: GetFirstIncompletePreviousPractice :one +WITH target AS ( + SELECT id, owner_type, owner_id, COALESCE(display_order, 0) AS display_order + FROM question_sets + WHERE id = $2::BIGINT + AND set_type = 'PRACTICE' + AND status = 'PUBLISHED' +), +candidates AS ( + SELECT qs.id, qs.title, COALESCE(qs.display_order, 0) AS display_order + FROM question_sets qs + JOIN target t + ON qs.owner_type = t.owner_type + AND qs.owner_id = t.owner_id + WHERE qs.set_type = 'PRACTICE' + AND qs.status = 'PUBLISHED' + AND COALESCE(qs.display_order, 0) < t.display_order +) +SELECT c.id, c.title, c.display_order +FROM candidates c +LEFT JOIN user_practice_progress upp + ON upp.question_set_id = c.id + AND upp.user_id = $1::BIGINT + AND upp.completed_at IS NOT NULL +WHERE upp.id IS NULL +ORDER BY c.display_order ASC, c.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 :execrows +INSERT INTO user_practice_progress ( + user_id, + sub_course_id, + question_set_id, + completed_at, + updated_at +) +SELECT + $1::BIGINT, + CASE + WHEN qs.owner_type = 'SUB_COURSE' THEN qs.owner_id + WHEN qs.owner_type = 'SUB_MODULE' THEN sm.legacy_sub_course_id + ELSE NULL + END, + qs.id, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM question_sets qs +LEFT JOIN sub_modules sm + ON qs.owner_type = 'SUB_MODULE' + AND qs.owner_id = sm.id +WHERE qs.id = $2::BIGINT +ON CONFLICT (user_id, question_set_id) DO UPDATE +SET completed_at = EXCLUDED.completed_at, + updated_at = EXCLUDED.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) (int64, error) { + result, err := q.db.Exec(ctx, MarkPracticeCompleted, arg.UserID, arg.QuestionSetID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/gen/db/sub_course_prerequisites.sql.go b/gen/db/sub_course_prerequisites.sql.go deleted file mode 100644 index 98ce4b2..0000000 --- a/gen/db/sub_course_prerequisites.sql.go +++ /dev/null @@ -1,187 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: sub_course_prerequisites.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const AddSubCoursePrerequisite = `-- name: AddSubCoursePrerequisite :one -INSERT INTO sub_course_prerequisites (sub_course_id, prerequisite_sub_course_id) -VALUES ($1, $2) -RETURNING id, sub_course_id, prerequisite_sub_course_id, created_at -` - -type AddSubCoursePrerequisiteParams struct { - SubCourseID int64 `json:"sub_course_id"` - PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` -} - -func (q *Queries) AddSubCoursePrerequisite(ctx context.Context, arg AddSubCoursePrerequisiteParams) (SubCoursePrerequisite, error) { - row := q.db.QueryRow(ctx, AddSubCoursePrerequisite, arg.SubCourseID, arg.PrerequisiteSubCourseID) - var i SubCoursePrerequisite - err := row.Scan( - &i.ID, - &i.SubCourseID, - &i.PrerequisiteSubCourseID, - &i.CreatedAt, - ) - return i, err -} - -const CountUnmetPrerequisites = `-- name: CountUnmetPrerequisites :one -SELECT COUNT(*)::bigint AS unmet_count -FROM sub_course_prerequisites p -WHERE p.sub_course_id = $1 - AND p.prerequisite_sub_course_id NOT IN ( - SELECT usp.sub_course_id - FROM user_sub_course_progress usp - WHERE usp.user_id = $2 - AND usp.status = 'COMPLETED' - ) -` - -type CountUnmetPrerequisitesParams struct { - SubCourseID int64 `json:"sub_course_id"` - UserID int64 `json:"user_id"` -} - -func (q *Queries) CountUnmetPrerequisites(ctx context.Context, arg CountUnmetPrerequisitesParams) (int64, error) { - row := q.db.QueryRow(ctx, CountUnmetPrerequisites, arg.SubCourseID, arg.UserID) - var unmet_count int64 - err := row.Scan(&unmet_count) - return unmet_count, err -} - -const DeleteAllPrerequisitesForSubCourse = `-- name: DeleteAllPrerequisitesForSubCourse :exec -DELETE FROM sub_course_prerequisites -WHERE sub_course_id = $1 -` - -func (q *Queries) DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error { - _, err := q.db.Exec(ctx, DeleteAllPrerequisitesForSubCourse, subCourseID) - return err -} - -const GetSubCourseDependents = `-- name: GetSubCourseDependents :many -SELECT - p.id, - p.sub_course_id, - p.prerequisite_sub_course_id, - p.created_at, - sc.title AS dependent_title, - sc.level AS dependent_level -FROM sub_course_prerequisites p -JOIN sub_courses sc ON sc.id = p.sub_course_id -WHERE p.prerequisite_sub_course_id = $1 -ORDER BY sc.display_order -` - -type GetSubCourseDependentsRow struct { - ID int64 `json:"id"` - SubCourseID int64 `json:"sub_course_id"` - PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - DependentTitle string `json:"dependent_title"` - DependentLevel string `json:"dependent_level"` -} - -func (q *Queries) GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]GetSubCourseDependentsRow, error) { - rows, err := q.db.Query(ctx, GetSubCourseDependents, prerequisiteSubCourseID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetSubCourseDependentsRow - for rows.Next() { - var i GetSubCourseDependentsRow - if err := rows.Scan( - &i.ID, - &i.SubCourseID, - &i.PrerequisiteSubCourseID, - &i.CreatedAt, - &i.DependentTitle, - &i.DependentLevel, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetSubCoursePrerequisites = `-- name: GetSubCoursePrerequisites :many -SELECT - p.id, - p.sub_course_id, - p.prerequisite_sub_course_id, - p.created_at, - sc.title AS prerequisite_title, - sc.level AS prerequisite_level, - sc.display_order AS prerequisite_display_order -FROM sub_course_prerequisites p -JOIN sub_courses sc ON sc.id = p.prerequisite_sub_course_id -WHERE p.sub_course_id = $1 -ORDER BY sc.display_order -` - -type GetSubCoursePrerequisitesRow struct { - ID int64 `json:"id"` - SubCourseID int64 `json:"sub_course_id"` - PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - PrerequisiteTitle string `json:"prerequisite_title"` - PrerequisiteLevel string `json:"prerequisite_level"` - PrerequisiteDisplayOrder int32 `json:"prerequisite_display_order"` -} - -func (q *Queries) GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]GetSubCoursePrerequisitesRow, error) { - rows, err := q.db.Query(ctx, GetSubCoursePrerequisites, subCourseID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetSubCoursePrerequisitesRow - for rows.Next() { - var i GetSubCoursePrerequisitesRow - if err := rows.Scan( - &i.ID, - &i.SubCourseID, - &i.PrerequisiteSubCourseID, - &i.CreatedAt, - &i.PrerequisiteTitle, - &i.PrerequisiteLevel, - &i.PrerequisiteDisplayOrder, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const RemoveSubCoursePrerequisite = `-- name: RemoveSubCoursePrerequisite :exec -DELETE FROM sub_course_prerequisites -WHERE sub_course_id = $1 AND prerequisite_sub_course_id = $2 -` - -type RemoveSubCoursePrerequisiteParams struct { - SubCourseID int64 `json:"sub_course_id"` - PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` -} - -func (q *Queries) RemoveSubCoursePrerequisite(ctx context.Context, arg RemoveSubCoursePrerequisiteParams) error { - _, err := q.db.Exec(ctx, RemoveSubCoursePrerequisite, arg.SubCourseID, arg.PrerequisiteSubCourseID) - return err -} diff --git a/gen/db/sub_course_videos.sql.go b/gen/db/sub_course_videos.sql.go deleted file mode 100644 index f19b47b..0000000 --- a/gen/db/sub_course_videos.sql.go +++ /dev/null @@ -1,441 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: sub_course_videos.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const ArchiveSubCourseVideo = `-- name: ArchiveSubCourseVideo :exec -UPDATE sub_course_videos -SET status = 'ARCHIVED' -WHERE id = $1 -` - -func (q *Queries) ArchiveSubCourseVideo(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, ArchiveSubCourseVideo, id) - return err -} - -const CreateSubCourseVideo = `-- name: CreateSubCourseVideo :one -INSERT INTO sub_course_videos ( - sub_course_id, - title, - description, - video_url, - duration, - resolution, - instructor_id, - thumbnail, - visibility, - display_order, - status, - vimeo_id, - vimeo_embed_url, - vimeo_player_html, - vimeo_status, - video_host_provider -) -VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, - COALESCE($10, 0), - COALESCE($11, 'DRAFT'), - $12, $13, $14, - COALESCE($15, 'pending'), - COALESCE($16, 'DIRECT') -) -RETURNING id, sub_course_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider -` - -type CreateSubCourseVideoParams struct { - SubCourseID int64 `json:"sub_course_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - VideoUrl string `json:"video_url"` - Duration int32 `json:"duration"` - Resolution pgtype.Text `json:"resolution"` - InstructorID pgtype.Text `json:"instructor_id"` - Thumbnail pgtype.Text `json:"thumbnail"` - Visibility pgtype.Text `json:"visibility"` - Column10 interface{} `json:"column_10"` - Column11 interface{} `json:"column_11"` - VimeoID pgtype.Text `json:"vimeo_id"` - VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"` - VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"` - Column15 interface{} `json:"column_15"` - Column16 interface{} `json:"column_16"` -} - -func (q *Queries) CreateSubCourseVideo(ctx context.Context, arg CreateSubCourseVideoParams) (SubCourseVideo, error) { - row := q.db.QueryRow(ctx, CreateSubCourseVideo, - arg.SubCourseID, - arg.Title, - arg.Description, - arg.VideoUrl, - arg.Duration, - arg.Resolution, - arg.InstructorID, - arg.Thumbnail, - arg.Visibility, - arg.Column10, - arg.Column11, - arg.VimeoID, - arg.VimeoEmbedUrl, - arg.VimeoPlayerHtml, - arg.Column15, - arg.Column16, - ) - var i SubCourseVideo - err := row.Scan( - &i.ID, - &i.SubCourseID, - &i.Title, - &i.Description, - &i.VideoUrl, - &i.Duration, - &i.Resolution, - &i.IsPublished, - &i.PublishDate, - &i.Visibility, - &i.InstructorID, - &i.Thumbnail, - &i.DisplayOrder, - &i.Status, - &i.VimeoID, - &i.VimeoEmbedUrl, - &i.VimeoPlayerHtml, - &i.VimeoStatus, - &i.VideoHostProvider, - ) - return i, err -} - -const DeleteSubCourseVideo = `-- name: DeleteSubCourseVideo :exec -DELETE FROM sub_course_videos -WHERE id = $1 -` - -func (q *Queries) DeleteSubCourseVideo(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, DeleteSubCourseVideo, id) - return err -} - -const GetPublishedVideosBySubCourse = `-- name: GetPublishedVideosBySubCourse :many -SELECT id, sub_course_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider -FROM sub_course_videos -WHERE sub_course_id = $1 - AND status = 'PUBLISHED' -ORDER BY display_order ASC, publish_date ASC -` - -func (q *Queries) GetPublishedVideosBySubCourse(ctx context.Context, subCourseID int64) ([]SubCourseVideo, error) { - rows, err := q.db.Query(ctx, GetPublishedVideosBySubCourse, subCourseID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []SubCourseVideo - for rows.Next() { - var i SubCourseVideo - if err := rows.Scan( - &i.ID, - &i.SubCourseID, - &i.Title, - &i.Description, - &i.VideoUrl, - &i.Duration, - &i.Resolution, - &i.IsPublished, - &i.PublishDate, - &i.Visibility, - &i.InstructorID, - &i.Thumbnail, - &i.DisplayOrder, - &i.Status, - &i.VimeoID, - &i.VimeoEmbedUrl, - &i.VimeoPlayerHtml, - &i.VimeoStatus, - &i.VideoHostProvider, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetSubCourseVideoByID = `-- name: GetSubCourseVideoByID :one -SELECT id, sub_course_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider -FROM sub_course_videos -WHERE id = $1 -` - -func (q *Queries) GetSubCourseVideoByID(ctx context.Context, id int64) (SubCourseVideo, error) { - row := q.db.QueryRow(ctx, GetSubCourseVideoByID, id) - var i SubCourseVideo - err := row.Scan( - &i.ID, - &i.SubCourseID, - &i.Title, - &i.Description, - &i.VideoUrl, - &i.Duration, - &i.Resolution, - &i.IsPublished, - &i.PublishDate, - &i.Visibility, - &i.InstructorID, - &i.Thumbnail, - &i.DisplayOrder, - &i.Status, - &i.VimeoID, - &i.VimeoEmbedUrl, - &i.VimeoPlayerHtml, - &i.VimeoStatus, - &i.VideoHostProvider, - ) - return i, err -} - -const GetVideosBySubCourse = `-- name: GetVideosBySubCourse :many -SELECT - COUNT(*) OVER () AS total_count, - id, - sub_course_id, - title, - description, - video_url, - duration, - resolution, - is_published, - publish_date, - visibility, - instructor_id, - thumbnail, - display_order, - status, - vimeo_id, - vimeo_embed_url, - vimeo_player_html, - vimeo_status, - video_host_provider -FROM sub_course_videos -WHERE sub_course_id = $1 - AND status != 'ARCHIVED' -ORDER BY display_order ASC, id ASC -` - -type GetVideosBySubCourseRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - SubCourseID int64 `json:"sub_course_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - VideoUrl string `json:"video_url"` - Duration int32 `json:"duration"` - Resolution pgtype.Text `json:"resolution"` - IsPublished bool `json:"is_published"` - PublishDate pgtype.Timestamptz `json:"publish_date"` - Visibility pgtype.Text `json:"visibility"` - InstructorID pgtype.Text `json:"instructor_id"` - Thumbnail pgtype.Text `json:"thumbnail"` - DisplayOrder int32 `json:"display_order"` - Status string `json:"status"` - VimeoID pgtype.Text `json:"vimeo_id"` - VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"` - VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"` - VimeoStatus pgtype.Text `json:"vimeo_status"` - VideoHostProvider pgtype.Text `json:"video_host_provider"` -} - -func (q *Queries) GetVideosBySubCourse(ctx context.Context, subCourseID int64) ([]GetVideosBySubCourseRow, error) { - rows, err := q.db.Query(ctx, GetVideosBySubCourse, subCourseID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetVideosBySubCourseRow - for rows.Next() { - var i GetVideosBySubCourseRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.SubCourseID, - &i.Title, - &i.Description, - &i.VideoUrl, - &i.Duration, - &i.Resolution, - &i.IsPublished, - &i.PublishDate, - &i.Visibility, - &i.InstructorID, - &i.Thumbnail, - &i.DisplayOrder, - &i.Status, - &i.VimeoID, - &i.VimeoEmbedUrl, - &i.VimeoPlayerHtml, - &i.VimeoStatus, - &i.VideoHostProvider, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetVideosByVimeoID = `-- name: GetVideosByVimeoID :one -SELECT id, sub_course_id, title, description, video_url, duration, resolution, is_published, publish_date, visibility, instructor_id, thumbnail, display_order, status, vimeo_id, vimeo_embed_url, vimeo_player_html, vimeo_status, video_host_provider FROM sub_course_videos -WHERE vimeo_id = $1 -` - -func (q *Queries) GetVideosByVimeoID(ctx context.Context, vimeoID pgtype.Text) (SubCourseVideo, error) { - row := q.db.QueryRow(ctx, GetVideosByVimeoID, vimeoID) - var i SubCourseVideo - err := row.Scan( - &i.ID, - &i.SubCourseID, - &i.Title, - &i.Description, - &i.VideoUrl, - &i.Duration, - &i.Resolution, - &i.IsPublished, - &i.PublishDate, - &i.Visibility, - &i.InstructorID, - &i.Thumbnail, - &i.DisplayOrder, - &i.Status, - &i.VimeoID, - &i.VimeoEmbedUrl, - &i.VimeoPlayerHtml, - &i.VimeoStatus, - &i.VideoHostProvider, - ) - return i, err -} - -const PublishSubCourseVideo = `-- name: PublishSubCourseVideo :exec -UPDATE sub_course_videos -SET - is_published = true, - publish_date = CURRENT_TIMESTAMP, - status = 'PUBLISHED' -WHERE id = $1 -` - -func (q *Queries) PublishSubCourseVideo(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, PublishSubCourseVideo, id) - return err -} - -const ReorderSubCourseVideos = `-- name: ReorderSubCourseVideos :exec -UPDATE sub_course_videos -SET display_order = bulk.position -FROM ( - SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position -) AS bulk -WHERE sub_course_videos.id = bulk.id -` - -type ReorderSubCourseVideosParams struct { - Ids []int64 `json:"ids"` - Positions []int32 `json:"positions"` -} - -func (q *Queries) ReorderSubCourseVideos(ctx context.Context, arg ReorderSubCourseVideosParams) error { - _, err := q.db.Exec(ctx, ReorderSubCourseVideos, arg.Ids, arg.Positions) - return err -} - -const UpdateSubCourseVideo = `-- name: UpdateSubCourseVideo :exec -UPDATE sub_course_videos -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - video_url = COALESCE($3, video_url), - duration = COALESCE($4, duration), - resolution = COALESCE($5, resolution), - visibility = COALESCE($6, visibility), - thumbnail = COALESCE($7, thumbnail), - display_order = COALESCE($8, display_order), - status = COALESCE($9, status), - vimeo_id = COALESCE($10, vimeo_id), - vimeo_embed_url = COALESCE($11, vimeo_embed_url), - vimeo_player_html = COALESCE($12, vimeo_player_html), - vimeo_status = COALESCE($13, vimeo_status), - video_host_provider = COALESCE($14, video_host_provider) -WHERE id = $15 -` - -type UpdateSubCourseVideoParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - VideoUrl string `json:"video_url"` - Duration int32 `json:"duration"` - Resolution pgtype.Text `json:"resolution"` - Visibility pgtype.Text `json:"visibility"` - Thumbnail pgtype.Text `json:"thumbnail"` - DisplayOrder int32 `json:"display_order"` - Status string `json:"status"` - VimeoID pgtype.Text `json:"vimeo_id"` - VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"` - VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"` - VimeoStatus pgtype.Text `json:"vimeo_status"` - VideoHostProvider pgtype.Text `json:"video_host_provider"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateSubCourseVideo(ctx context.Context, arg UpdateSubCourseVideoParams) error { - _, err := q.db.Exec(ctx, UpdateSubCourseVideo, - arg.Title, - arg.Description, - arg.VideoUrl, - arg.Duration, - arg.Resolution, - arg.Visibility, - arg.Thumbnail, - arg.DisplayOrder, - arg.Status, - arg.VimeoID, - arg.VimeoEmbedUrl, - arg.VimeoPlayerHtml, - arg.VimeoStatus, - arg.VideoHostProvider, - arg.ID, - ) - return err -} - -const UpdateVimeoStatus = `-- name: UpdateVimeoStatus :exec -UPDATE sub_course_videos -SET - vimeo_status = $1 -WHERE id = $2 -` - -type UpdateVimeoStatusParams struct { - VimeoStatus pgtype.Text `json:"vimeo_status"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateVimeoStatus(ctx context.Context, arg UpdateVimeoStatusParams) error { - _, err := q.db.Exec(ctx, UpdateVimeoStatus, arg.VimeoStatus, arg.ID) - return err -} diff --git a/gen/db/sub_courses.sql.go b/gen/db/sub_courses.sql.go deleted file mode 100644 index 2d0eb7e..0000000 --- a/gen/db/sub_courses.sql.go +++ /dev/null @@ -1,356 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: sub_courses.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const CreateSubCourse = `-- name: CreateSubCourse :one -INSERT INTO sub_courses ( - course_id, - title, - description, - thumbnail, - display_order, - level, - sub_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 { - CourseID int64 `json:"course_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - Column5 interface{} `json:"column_5"` - Level string `json:"level"` - SubLevel string `json:"sub_level"` - Column8 interface{} `json:"column_8"` -} - -func (q *Queries) CreateSubCourse(ctx context.Context, arg CreateSubCourseParams) (SubCourse, error) { - row := q.db.QueryRow(ctx, CreateSubCourse, - arg.CourseID, - arg.Title, - arg.Description, - arg.Thumbnail, - arg.Column5, - arg.Level, - arg.SubLevel, - arg.Column8, - ) - var i SubCourse - err := row.Scan( - &i.ID, - &i.CourseID, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.DisplayOrder, - &i.Level, - &i.IsActive, - &i.SubLevel, - ) - return i, err -} - -const DeactivateSubCourse = `-- name: DeactivateSubCourse :exec -UPDATE sub_courses -SET is_active = FALSE -WHERE id = $1 -` - -func (q *Queries) DeactivateSubCourse(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, DeactivateSubCourse, id) - return err -} - -const DeleteSubCourse = `-- name: DeleteSubCourse :one -DELETE FROM sub_courses -WHERE id = $1 -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) { - row := q.db.QueryRow(ctx, DeleteSubCourse, id) - var i SubCourse - err := row.Scan( - &i.ID, - &i.CourseID, - &i.Title, - &i.Description, - &i.Thumbnail, - &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, sub_level -FROM sub_courses -WHERE id = $1 -` - -func (q *Queries) GetSubCourseByID(ctx context.Context, id int64) (SubCourse, error) { - row := q.db.QueryRow(ctx, GetSubCourseByID, id) - var i SubCourse - err := row.Scan( - &i.ID, - &i.CourseID, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.DisplayOrder, - &i.Level, - &i.IsActive, - &i.SubLevel, - ) - return i, err -} - -const GetSubCoursesByCourse = `-- name: GetSubCoursesByCourse :many -SELECT - COUNT(*) OVER () AS total_count, - id, - course_id, - title, - description, - thumbnail, - display_order, - level, - sub_level, - is_active -FROM sub_courses -WHERE course_id = $1 -ORDER BY display_order ASC, id ASC -` - -type GetSubCoursesByCourseRow struct { - TotalCount int64 `json:"total_count"` - 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) GetSubCoursesByCourse(ctx context.Context, courseID int64) ([]GetSubCoursesByCourseRow, error) { - rows, err := q.db.Query(ctx, GetSubCoursesByCourse, courseID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetSubCoursesByCourseRow - for rows.Next() { - var i GetSubCoursesByCourseRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.CourseID, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.DisplayOrder, - &i.Level, - &i.SubLevel, - &i.IsActive, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const ListActiveSubCourses = `-- name: ListActiveSubCourses :many -SELECT - id, - course_id, - title, - description, - thumbnail, - display_order, - level, - sub_level, - is_active -FROM sub_courses -WHERE is_active = TRUE -ORDER BY display_order ASC -` - -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 []ListActiveSubCoursesRow - for rows.Next() { - var i ListActiveSubCoursesRow - if err := rows.Scan( - &i.ID, - &i.CourseID, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.DisplayOrder, - &i.Level, - &i.SubLevel, - &i.IsActive, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const ListSubCoursesByCourse = `-- name: ListSubCoursesByCourse :many -SELECT - id, - course_id, - title, - description, - thumbnail, - display_order, - level, - sub_level, - is_active -FROM sub_courses -WHERE course_id = $1 - AND is_active = TRUE -ORDER BY display_order ASC, id ASC -` - -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 []ListSubCoursesByCourseRow - for rows.Next() { - var i ListSubCoursesByCourseRow - if err := rows.Scan( - &i.ID, - &i.CourseID, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.DisplayOrder, - &i.Level, - &i.SubLevel, - &i.IsActive, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const ReorderSubCourses = `-- name: ReorderSubCourses :exec -UPDATE sub_courses -SET display_order = bulk.position -FROM ( - SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position -) AS bulk -WHERE sub_courses.id = bulk.id -` - -type ReorderSubCoursesParams struct { - Ids []int64 `json:"ids"` - Positions []int32 `json:"positions"` -} - -func (q *Queries) ReorderSubCourses(ctx context.Context, arg ReorderSubCoursesParams) error { - _, err := q.db.Exec(ctx, ReorderSubCourses, arg.Ids, arg.Positions) - return err -} - -const UpdateSubCourse = `-- name: UpdateSubCourse :exec -UPDATE sub_courses -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - thumbnail = COALESCE($3, thumbnail), - display_order = COALESCE($4, display_order), - level = COALESCE($5, level), - sub_level = COALESCE($6, sub_level), - is_active = COALESCE($7, is_active) -WHERE id = $8 -` - -type UpdateSubCourseParams struct { - 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"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateSubCourse(ctx context.Context, arg UpdateSubCourseParams) error { - _, err := q.db.Exec(ctx, UpdateSubCourse, - arg.Title, - arg.Description, - arg.Thumbnail, - arg.DisplayOrder, - arg.Level, - arg.SubLevel, - arg.IsActive, - arg.ID, - ) - return err -} diff --git a/gen/db/user_practice_progress.sql.go b/gen/db/user_practice_progress.sql.go deleted file mode 100644 index f077703..0000000 --- a/gen/db/user_practice_progress.sql.go +++ /dev/null @@ -1,102 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: user_practice_progress.sql - -package dbgen - -import ( - "context" -) - -const GetFirstIncompletePreviousPractice = `-- name: GetFirstIncompletePreviousPractice :one -SELECT - p.id, - p.title, - p.display_order -FROM question_sets target -JOIN question_sets p - ON p.owner_type = 'SUB_COURSE' - AND p.owner_id = target.owner_id - AND p.set_type = 'PRACTICE' - AND p.status = 'PUBLISHED' - AND ( - p.display_order < target.display_order OR - (p.display_order = target.display_order AND p.id < target.id) - ) -LEFT JOIN user_practice_progress upp - ON upp.question_set_id = p.id - AND upp.user_id = $1::BIGINT - AND upp.completed_at IS NOT NULL -WHERE target.id = $2::BIGINT - AND target.set_type = 'PRACTICE' - AND target.owner_type = 'SUB_COURSE' - AND target.status = 'PUBLISHED' - AND upp.question_set_id IS NULL -ORDER BY p.display_order ASC, p.id ASC -LIMIT 1 -` - -type GetFirstIncompletePreviousPracticeParams struct { - UserID int64 `json:"user_id"` - QuestionSetID int64 `json:"question_set_id"` -} - -type GetFirstIncompletePreviousPracticeRow struct { - ID int64 `json:"id"` - Title string `json:"title"` - DisplayOrder int32 `json:"display_order"` -} - -func (q *Queries) GetFirstIncompletePreviousPractice(ctx context.Context, arg GetFirstIncompletePreviousPracticeParams) (GetFirstIncompletePreviousPracticeRow, error) { - row := q.db.QueryRow(ctx, GetFirstIncompletePreviousPractice, arg.UserID, arg.QuestionSetID) - var i GetFirstIncompletePreviousPracticeRow - err := row.Scan(&i.ID, &i.Title, &i.DisplayOrder) - return i, err -} - -const MarkPracticeCompleted = `-- name: MarkPracticeCompleted :one -INSERT INTO user_practice_progress ( - user_id, - sub_course_id, - question_set_id, - completed_at, - updated_at -) -SELECT - $1::BIGINT, - qs.owner_id::BIGINT, - qs.id, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -FROM question_sets qs -WHERE qs.id = $2::BIGINT - AND qs.set_type = 'PRACTICE' - AND qs.owner_type = 'SUB_COURSE' - AND qs.status = 'PUBLISHED' -ON CONFLICT (user_id, question_set_id) -DO UPDATE SET - completed_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP -RETURNING id, user_id, sub_course_id, question_set_id, completed_at, created_at, updated_at -` - -type MarkPracticeCompletedParams struct { - UserID int64 `json:"user_id"` - QuestionSetID int64 `json:"question_set_id"` -} - -func (q *Queries) MarkPracticeCompleted(ctx context.Context, arg MarkPracticeCompletedParams) (UserPracticeProgress, error) { - row := q.db.QueryRow(ctx, MarkPracticeCompleted, arg.UserID, arg.QuestionSetID) - var i UserPracticeProgress - err := row.Scan( - &i.ID, - &i.UserID, - &i.SubCourseID, - &i.QuestionSetID, - &i.CompletedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} diff --git a/gen/db/user_sub_course_progress.sql.go b/gen/db/user_sub_course_progress.sql.go deleted file mode 100644 index f72c76e..0000000 --- a/gen/db/user_sub_course_progress.sql.go +++ /dev/null @@ -1,279 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: user_sub_course_progress.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const CompleteSubCourse = `-- name: CompleteSubCourse :exec -UPDATE user_sub_course_progress -SET - status = 'COMPLETED', - progress_percentage = 100, - completed_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP -WHERE user_id = $1 AND sub_course_id = $2 -` - -type CompleteSubCourseParams struct { - UserID int64 `json:"user_id"` - SubCourseID int64 `json:"sub_course_id"` -} - -func (q *Queries) CompleteSubCourse(ctx context.Context, arg CompleteSubCourseParams) error { - _, err := q.db.Exec(ctx, CompleteSubCourse, arg.UserID, arg.SubCourseID) - return err -} - -const DeleteUserSubCourseProgress = `-- name: DeleteUserSubCourseProgress :exec -DELETE FROM user_sub_course_progress -WHERE user_id = $1 AND sub_course_id = $2 -` - -type DeleteUserSubCourseProgressParams struct { - UserID int64 `json:"user_id"` - SubCourseID int64 `json:"sub_course_id"` -} - -func (q *Queries) DeleteUserSubCourseProgress(ctx context.Context, arg DeleteUserSubCourseProgressParams) error { - _, err := q.db.Exec(ctx, DeleteUserSubCourseProgress, arg.UserID, arg.SubCourseID) - return err -} - -const GetSubCoursesWithProgressByCourse = `-- name: GetSubCoursesWithProgressByCourse :many -SELECT - sc.id AS sub_course_id, - sc.title, - sc.description, - sc.thumbnail, - sc.display_order, - sc.level, - sc.is_active, - COALESCE(usp.status, 'NOT_STARTED') AS progress_status, - COALESCE(usp.progress_percentage, 0)::smallint AS progress_percentage, - usp.started_at, - usp.completed_at, - (SELECT COUNT(*)::bigint - FROM sub_course_prerequisites p - WHERE p.sub_course_id = sc.id - AND p.prerequisite_sub_course_id NOT IN ( - SELECT usp2.sub_course_id - FROM user_sub_course_progress usp2 - WHERE usp2.user_id = $1 - AND usp2.status = 'COMPLETED' - ) - ) AS unmet_prerequisites_count -FROM sub_courses sc -LEFT JOIN user_sub_course_progress usp - ON usp.sub_course_id = sc.id AND usp.user_id = $1 -WHERE sc.course_id = $2 - AND sc.is_active = true -ORDER BY sc.display_order -` - -type GetSubCoursesWithProgressByCourseParams struct { - UserID int64 `json:"user_id"` - CourseID int64 `json:"course_id"` -} - -type GetSubCoursesWithProgressByCourseRow struct { - SubCourseID int64 `json:"sub_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"` - IsActive bool `json:"is_active"` - ProgressStatus string `json:"progress_status"` - ProgressPercentage int16 `json:"progress_percentage"` - StartedAt pgtype.Timestamptz `json:"started_at"` - CompletedAt pgtype.Timestamptz `json:"completed_at"` - UnmetPrerequisitesCount int64 `json:"unmet_prerequisites_count"` -} - -func (q *Queries) GetSubCoursesWithProgressByCourse(ctx context.Context, arg GetSubCoursesWithProgressByCourseParams) ([]GetSubCoursesWithProgressByCourseRow, error) { - rows, err := q.db.Query(ctx, GetSubCoursesWithProgressByCourse, arg.UserID, arg.CourseID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetSubCoursesWithProgressByCourseRow - for rows.Next() { - var i GetSubCoursesWithProgressByCourseRow - if err := rows.Scan( - &i.SubCourseID, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.DisplayOrder, - &i.Level, - &i.IsActive, - &i.ProgressStatus, - &i.ProgressPercentage, - &i.StartedAt, - &i.CompletedAt, - &i.UnmetPrerequisitesCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetUserCourseProgress = `-- name: GetUserCourseProgress :many -SELECT - usp.id, - usp.user_id, - usp.sub_course_id, - usp.status, - usp.progress_percentage, - usp.started_at, - usp.completed_at, - usp.created_at, - usp.updated_at, - sc.title AS sub_course_title, - sc.level AS sub_course_level, - sc.display_order AS sub_course_display_order -FROM user_sub_course_progress usp -JOIN sub_courses sc ON sc.id = usp.sub_course_id -WHERE usp.user_id = $1 AND sc.course_id = $2 -ORDER BY sc.display_order -` - -type GetUserCourseProgressParams struct { - UserID int64 `json:"user_id"` - CourseID int64 `json:"course_id"` -} - -type GetUserCourseProgressRow struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - SubCourseID int64 `json:"sub_course_id"` - Status string `json:"status"` - ProgressPercentage int16 `json:"progress_percentage"` - StartedAt pgtype.Timestamptz `json:"started_at"` - CompletedAt pgtype.Timestamptz `json:"completed_at"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - SubCourseTitle string `json:"sub_course_title"` - SubCourseLevel string `json:"sub_course_level"` - SubCourseDisplayOrder int32 `json:"sub_course_display_order"` -} - -func (q *Queries) GetUserCourseProgress(ctx context.Context, arg GetUserCourseProgressParams) ([]GetUserCourseProgressRow, error) { - rows, err := q.db.Query(ctx, GetUserCourseProgress, arg.UserID, arg.CourseID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetUserCourseProgressRow - for rows.Next() { - var i GetUserCourseProgressRow - if err := rows.Scan( - &i.ID, - &i.UserID, - &i.SubCourseID, - &i.Status, - &i.ProgressPercentage, - &i.StartedAt, - &i.CompletedAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.SubCourseTitle, - &i.SubCourseLevel, - &i.SubCourseDisplayOrder, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetUserSubCourseProgress = `-- name: GetUserSubCourseProgress :one -SELECT id, user_id, sub_course_id, status, progress_percentage, started_at, completed_at, created_at, updated_at FROM user_sub_course_progress -WHERE user_id = $1 AND sub_course_id = $2 -` - -type GetUserSubCourseProgressParams struct { - UserID int64 `json:"user_id"` - SubCourseID int64 `json:"sub_course_id"` -} - -func (q *Queries) GetUserSubCourseProgress(ctx context.Context, arg GetUserSubCourseProgressParams) (UserSubCourseProgress, error) { - row := q.db.QueryRow(ctx, GetUserSubCourseProgress, arg.UserID, arg.SubCourseID) - var i UserSubCourseProgress - err := row.Scan( - &i.ID, - &i.UserID, - &i.SubCourseID, - &i.Status, - &i.ProgressPercentage, - &i.StartedAt, - &i.CompletedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const StartSubCourseProgress = `-- name: StartSubCourseProgress :one -INSERT INTO user_sub_course_progress (user_id, sub_course_id) -VALUES ($1, $2) -ON CONFLICT (user_id, sub_course_id) DO NOTHING -RETURNING id, user_id, sub_course_id, status, progress_percentage, started_at, completed_at, created_at, updated_at -` - -type StartSubCourseProgressParams struct { - UserID int64 `json:"user_id"` - SubCourseID int64 `json:"sub_course_id"` -} - -func (q *Queries) StartSubCourseProgress(ctx context.Context, arg StartSubCourseProgressParams) (UserSubCourseProgress, error) { - row := q.db.QueryRow(ctx, StartSubCourseProgress, arg.UserID, arg.SubCourseID) - var i UserSubCourseProgress - err := row.Scan( - &i.ID, - &i.UserID, - &i.SubCourseID, - &i.Status, - &i.ProgressPercentage, - &i.StartedAt, - &i.CompletedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - -const UpdateSubCourseProgress = `-- name: UpdateSubCourseProgress :exec -UPDATE user_sub_course_progress -SET - progress_percentage = $1, - updated_at = CURRENT_TIMESTAMP -WHERE user_id = $2 AND sub_course_id = $3 -` - -type UpdateSubCourseProgressParams struct { - ProgressPercentage int16 `json:"progress_percentage"` - UserID int64 `json:"user_id"` - SubCourseID int64 `json:"sub_course_id"` -} - -func (q *Queries) UpdateSubCourseProgress(ctx context.Context, arg UpdateSubCourseProgressParams) error { - _, err := q.db.Exec(ctx, UpdateSubCourseProgress, arg.ProgressPercentage, arg.UserID, arg.SubCourseID) - return err -} diff --git a/gen/db/user_sub_course_video_progress.sql.go b/gen/db/user_sub_course_video_progress.sql.go deleted file mode 100644 index c1815c9..0000000 --- a/gen/db/user_sub_course_video_progress.sql.go +++ /dev/null @@ -1,95 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: user_sub_course_video_progress.sql - -package dbgen - -import ( - "context" -) - -const GetFirstIncompletePreviousVideo = `-- name: GetFirstIncompletePreviousVideo :one -SELECT - v.id, - v.title, - v.display_order -FROM sub_course_videos target -JOIN sub_course_videos v - ON v.sub_course_id = target.sub_course_id - AND v.status = 'PUBLISHED' - AND ( - v.display_order < target.display_order OR - (v.display_order = target.display_order AND v.id < target.id) - ) -LEFT JOIN user_sub_course_video_progress p - ON p.video_id = v.id - AND p.user_id = $1::BIGINT - AND p.completed_at IS NOT NULL -WHERE target.id = $2::BIGINT - AND p.video_id IS NULL -ORDER BY v.display_order ASC, v.id ASC -LIMIT 1 -` - -type GetFirstIncompletePreviousVideoParams struct { - UserID int64 `json:"user_id"` - VideoID int64 `json:"video_id"` -} - -type GetFirstIncompletePreviousVideoRow struct { - ID int64 `json:"id"` - Title string `json:"title"` - DisplayOrder int32 `json:"display_order"` -} - -func (q *Queries) GetFirstIncompletePreviousVideo(ctx context.Context, arg GetFirstIncompletePreviousVideoParams) (GetFirstIncompletePreviousVideoRow, error) { - row := q.db.QueryRow(ctx, GetFirstIncompletePreviousVideo, arg.UserID, arg.VideoID) - var i GetFirstIncompletePreviousVideoRow - err := row.Scan(&i.ID, &i.Title, &i.DisplayOrder) - return i, err -} - -const MarkVideoCompleted = `-- name: MarkVideoCompleted :one -INSERT INTO user_sub_course_video_progress ( - user_id, - sub_course_id, - video_id, - completed_at, - updated_at -) -SELECT - $1::BIGINT, - v.sub_course_id, - v.id, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -FROM sub_course_videos v -WHERE v.id = $2::BIGINT - AND v.status = 'PUBLISHED' -ON CONFLICT (user_id, video_id) -DO UPDATE SET - completed_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP -RETURNING id, user_id, sub_course_id, video_id, completed_at, created_at, updated_at -` - -type MarkVideoCompletedParams struct { - UserID int64 `json:"user_id"` - VideoID int64 `json:"video_id"` -} - -func (q *Queries) MarkVideoCompleted(ctx context.Context, arg MarkVideoCompletedParams) (UserSubCourseVideoProgress, error) { - row := q.db.QueryRow(ctx, MarkVideoCompleted, arg.UserID, arg.VideoID) - var i UserSubCourseVideoProgress - err := row.Scan( - &i.ID, - &i.UserID, - &i.SubCourseID, - &i.VideoID, - &i.CompletedAt, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} diff --git a/internal/repository/course_catagories.go b/internal/repository/course_catagories.go index 72e9f85..0e13d9c 100644 --- a/internal/repository/course_catagories.go +++ b/internal/repository/course_catagories.go @@ -3,13 +3,13 @@ package repository import ( dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/domain" - "Yimaru-Backend/internal/ports" "context" "github.com/jackc/pgx/v5/pgtype" ) -func NewCourseStore(s *Store) ports.CourseStore { return s } +func NewCourseStore(s *Store) *Store { return s } +func NewProgressionStore(s *Store) *Store { return s } func (s *Store) CreateCourseCategory( ctx context.Context, diff --git a/internal/repository/learning_tree.go b/internal/repository/learning_tree.go deleted file mode 100644 index 74e095c..0000000 --- a/internal/repository/learning_tree.go +++ /dev/null @@ -1,162 +0,0 @@ -package repository - -import ( - "Yimaru-Backend/internal/domain" - "context" - "fmt" - - "github.com/jackc/pgx/v5/pgtype" -) - -func (s *Store) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) { - rows, err := s.queries.GetFullLearningTree(ctx) - if err != nil { - return nil, err - } - - coursesMap := make(map[int64]*domain.TreeCourse) - - for _, row := range rows { - course, ok := coursesMap[row.CourseID] - if !ok { - course = &domain.TreeCourse{ - ID: row.CourseID, - Title: row.CourseTitle, - SubCourses: []domain.TreeSubCourse{}, - } - coursesMap[row.CourseID] = course - } - - if row.SubCourseID.Valid { - subCourse := domain.TreeSubCourse{ - ID: row.SubCourseID.Int64, - Title: row.SubCourseTitle.String, - Level: row.SubCourseLevel.String, - SubLevel: row.SubCourseSubLevel.String, - } - course.SubCourses = append(course.SubCourses, subCourse) - } - } - - courses := make([]domain.TreeCourse, 0, len(coursesMap)) - for _, course := range coursesMap { - courses = append(courses, *course) - } - - return courses, nil -} - -func (s *Store) GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) { - rows, err := s.queries.GetCourseLearningPath(ctx, courseID) - if err != nil { - return domain.LearningPath{}, err - } - if len(rows) == 0 { - return domain.LearningPath{}, fmt.Errorf("course not found") - } - - first := rows[0] - path := domain.LearningPath{ - CourseID: first.CourseID, - CourseTitle: first.CourseTitle, - Description: ptrString(first.CourseDescription), - Thumbnail: ptrString(first.CourseThumbnail), - IntroVideoURL: ptrString(first.CourseIntroVideoUrl), - CategoryID: first.CategoryID, - CategoryName: first.CategoryName, - SubCourses: []domain.LearningPathSubCourse{}, - } - - for _, row := range rows { - if !row.SubCourseID.Valid { - continue - } - - scID := row.SubCourseID.Int64 - - // Fetch prerequisites, videos, practices for this sub-course - prerequisites, _ := s.getSubCoursePrerequisitesForPath(ctx, scID) - videos, _ := s.getSubCourseVideosForPath(ctx, scID) - practices, _ := s.getSubCoursePracticesForPath(ctx, scID) - - sc := domain.LearningPathSubCourse{ - ID: scID, - Title: row.SubCourseTitle.String, - Description: ptrString(row.SubCourseDescription), - 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, - Prerequisites: prerequisites, - Videos: videos, - Practices: practices, - } - path.SubCourses = append(path.SubCourses, sc) - } - - return path, nil -} - -func (s *Store) getSubCoursePrerequisitesForPath(ctx context.Context, subCourseID int64) ([]domain.LearningPathPrerequisite, error) { - rows, err := s.queries.GetSubCoursePrerequisitesForLearningPath(ctx, subCourseID) - if err != nil { - return nil, err - } - result := make([]domain.LearningPathPrerequisite, len(rows)) - for i, row := range rows { - result[i] = domain.LearningPathPrerequisite{ - SubCourseID: row.PrerequisiteSubCourseID, - Title: row.Title, - Level: row.Level, - SubLevel: row.SubLevel, - } - } - return result, nil -} - -func (s *Store) getSubCourseVideosForPath(ctx context.Context, subCourseID int64) ([]domain.LearningPathVideo, error) { - rows, err := s.queries.GetSubCourseVideosForLearningPath(ctx, subCourseID) - if err != nil { - return nil, err - } - result := make([]domain.LearningPathVideo, len(rows)) - for i, row := range rows { - result[i] = domain.LearningPathVideo{ - ID: row.ID, - Title: row.Title, - Description: ptrString(row.Description), - VideoURL: row.VideoUrl, - Duration: row.Duration, - Resolution: ptrString(row.Resolution), - DisplayOrder: row.DisplayOrder, - VimeoID: ptrString(row.VimeoID), - VimeoEmbedURL: ptrString(row.VimeoEmbedUrl), - VideoHostProvider: ptrString(row.VideoHostProvider), - } - } - return result, nil -} - -func (s *Store) getSubCoursePracticesForPath(ctx context.Context, subCourseID int64) ([]domain.LearningPathPractice, error) { - ownerID := pgtype.Int8{Int64: subCourseID, Valid: true} - rows, err := s.queries.GetSubCoursePracticesForLearningPath(ctx, ownerID) - if err != nil { - return nil, err - } - result := make([]domain.LearningPathPractice, len(rows)) - for i, row := range rows { - result[i] = domain.LearningPathPractice{ - ID: row.ID, - Title: row.Title, - Description: ptrString(row.Description), - Persona: ptrString(row.Persona), - Status: row.Status, - IntroVideoURL: ptrString(row.IntroVideoUrl), - QuestionCount: row.QuestionCount, - } - } - return result, nil -} diff --git a/internal/repository/progression.go b/internal/repository/progression.go deleted file mode 100644 index 055eccd..0000000 --- a/internal/repository/progression.go +++ /dev/null @@ -1,310 +0,0 @@ -package repository - -import ( - dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" - "Yimaru-Backend/internal/ports" - "context" - "errors" - "time" - - "github.com/jackc/pgx/v5" -) - -func NewProgressionStore(s *Store) ports.ProgressionStore { return s } - -func (s *Store) AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error { - _, err := s.queries.AddSubCoursePrerequisite(ctx, dbgen.AddSubCoursePrerequisiteParams{ - SubCourseID: subCourseID, - PrerequisiteSubCourseID: prerequisiteSubCourseID, - }) - return err -} - -func (s *Store) RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error { - return s.queries.RemoveSubCoursePrerequisite(ctx, dbgen.RemoveSubCoursePrerequisiteParams{ - SubCourseID: subCourseID, - PrerequisiteSubCourseID: prerequisiteSubCourseID, - }) -} - -func (s *Store) GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error) { - rows, err := s.queries.GetSubCoursePrerequisites(ctx, subCourseID) - if err != nil { - return nil, err - } - - prereqs := make([]domain.SubCoursePrerequisite, len(rows)) - for i, row := range rows { - prereqs[i] = domain.SubCoursePrerequisite{ - ID: row.ID, - SubCourseID: row.SubCourseID, - PrerequisiteSubCourseID: row.PrerequisiteSubCourseID, - CreatedAt: row.CreatedAt.Time, - PrerequisiteTitle: row.PrerequisiteTitle, - PrerequisiteLevel: row.PrerequisiteLevel, - PrerequisiteDisplayOrder: row.PrerequisiteDisplayOrder, - } - } - return prereqs, nil -} - -func (s *Store) GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error) { - rows, err := s.queries.GetSubCourseDependents(ctx, prerequisiteSubCourseID) - if err != nil { - return nil, err - } - - deps := make([]domain.SubCourseDependent, len(rows)) - for i, row := range rows { - deps[i] = domain.SubCourseDependent{ - ID: row.ID, - SubCourseID: row.SubCourseID, - PrerequisiteSubCourseID: row.PrerequisiteSubCourseID, - CreatedAt: row.CreatedAt.Time, - DependentTitle: row.DependentTitle, - DependentLevel: row.DependentLevel, - } - } - return deps, nil -} - -func (s *Store) CountUnmetPrerequisites(ctx context.Context, subCourseID, userID int64) (int64, error) { - return s.queries.CountUnmetPrerequisites(ctx, dbgen.CountUnmetPrerequisitesParams{ - SubCourseID: subCourseID, - UserID: userID, - }) -} - -func (s *Store) DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error { - return s.queries.DeleteAllPrerequisitesForSubCourse(ctx, subCourseID) -} - -func (s *Store) StartSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) { - row, err := s.queries.StartSubCourseProgress(ctx, dbgen.StartSubCourseProgressParams{ - UserID: userID, - SubCourseID: subCourseID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return s.GetUserSubCourseProgress(ctx, userID, subCourseID) - } - return domain.UserSubCourseProgress{}, err - } - return mapUserSubCourseProgress(row), nil -} - -func (s *Store) UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error { - return s.queries.UpdateSubCourseProgress(ctx, dbgen.UpdateSubCourseProgressParams{ - ProgressPercentage: percentage, - UserID: userID, - SubCourseID: subCourseID, - }) -} - -func (s *Store) CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error { - return s.queries.CompleteSubCourse(ctx, dbgen.CompleteSubCourseParams{ - UserID: userID, - SubCourseID: subCourseID, - }) -} - -func (s *Store) RecalculateSubCourseProgress(ctx context.Context, userID, subCourseID int64) error { - const query = ` - WITH totals AS ( - SELECT - (SELECT COUNT(*)::INT - FROM sub_course_videos v - WHERE v.sub_course_id = $2 - AND v.status = 'PUBLISHED') AS total_videos, - (SELECT COUNT(*)::INT - FROM question_sets qs - WHERE qs.owner_type = 'SUB_COURSE' - AND qs.owner_id = $2 - AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED') AS total_practices - ), - completed AS ( - SELECT - (SELECT COUNT(*)::INT - FROM user_sub_course_video_progress uv - JOIN sub_course_videos v ON v.id = uv.video_id - WHERE uv.user_id = $1 - AND uv.sub_course_id = $2 - AND uv.completed_at IS NOT NULL - AND v.status = 'PUBLISHED') AS completed_videos, - (SELECT COUNT(*)::INT - FROM user_practice_progress up - JOIN question_sets qs ON qs.id = up.question_set_id - WHERE up.user_id = $1 - AND up.sub_course_id = $2 - AND up.completed_at IS NOT NULL - AND qs.owner_type = 'SUB_COURSE' - AND qs.owner_id = $2 - AND qs.set_type = 'PRACTICE' - AND qs.status = 'PUBLISHED') AS completed_practices - ), - stats AS ( - SELECT - (total_videos + total_practices) AS total_items, - (completed_videos + completed_practices) AS completed_items - FROM totals, completed - ) - INSERT INTO user_sub_course_progress ( - user_id, - sub_course_id, - status, - progress_percentage, - started_at, - completed_at, - updated_at - ) - SELECT - $1, - $2, - CASE - WHEN stats.total_items > 0 AND stats.completed_items >= stats.total_items THEN 'COMPLETED' - WHEN stats.completed_items > 0 THEN 'IN_PROGRESS' - ELSE 'NOT_STARTED' - END, - CASE - WHEN stats.total_items = 0 THEN 0 - ELSE ROUND((stats.completed_items::NUMERIC * 100.0) / stats.total_items::NUMERIC)::SMALLINT - END, - CASE - WHEN stats.completed_items > 0 THEN CURRENT_TIMESTAMP - ELSE NULL - END, - CASE - WHEN stats.total_items > 0 AND stats.completed_items >= stats.total_items THEN CURRENT_TIMESTAMP - ELSE NULL - END, - CURRENT_TIMESTAMP - FROM stats - ON CONFLICT (user_id, sub_course_id) DO UPDATE SET - status = EXCLUDED.status, - progress_percentage = EXCLUDED.progress_percentage, - started_at = COALESCE(user_sub_course_progress.started_at, EXCLUDED.started_at), - completed_at = EXCLUDED.completed_at, - updated_at = EXCLUDED.updated_at; - ` - - _, err := s.conn.Exec(ctx, query, userID, subCourseID) - return err -} - -func (s *Store) GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) { - row, err := s.queries.GetUserSubCourseProgress(ctx, dbgen.GetUserSubCourseProgressParams{ - UserID: userID, - SubCourseID: subCourseID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.UserSubCourseProgress{}, domain.ErrProgressNotFound - } - return domain.UserSubCourseProgress{}, err - } - return mapUserSubCourseProgress(row), nil -} - -func (s *Store) GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error) { - rows, err := s.queries.GetUserCourseProgress(ctx, dbgen.GetUserCourseProgressParams{ - UserID: userID, - CourseID: courseID, - }) - if err != nil { - return nil, err - } - - items := make([]domain.UserCourseProgressItem, len(rows)) - for i, row := range rows { - var startedAt, completedAt *time.Time - if row.StartedAt.Valid { - startedAt = &row.StartedAt.Time - } - if row.CompletedAt.Valid { - completedAt = &row.CompletedAt.Time - } - var updatedAt *time.Time - if row.UpdatedAt.Valid { - updatedAt = &row.UpdatedAt.Time - } - items[i] = domain.UserCourseProgressItem{ - ID: row.ID, - UserID: row.UserID, - SubCourseID: row.SubCourseID, - Status: domain.ProgressStatus(row.Status), - ProgressPercentage: row.ProgressPercentage, - StartedAt: startedAt, - CompletedAt: completedAt, - CreatedAt: row.CreatedAt.Time, - UpdatedAt: updatedAt, - SubCourseTitle: row.SubCourseTitle, - SubCourseLevel: row.SubCourseLevel, - SubCourseDisplayOrder: row.SubCourseDisplayOrder, - } - } - return items, nil -} - -func (s *Store) GetSubCoursesWithProgressByCourse(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error) { - rows, err := s.queries.GetSubCoursesWithProgressByCourse(ctx, dbgen.GetSubCoursesWithProgressByCourseParams{ - UserID: userID, - CourseID: courseID, - }) - if err != nil { - return nil, err - } - - items := make([]domain.SubCourseWithProgress, len(rows)) - for i, row := range rows { - var startedAt, completedAt *time.Time - if row.StartedAt.Valid { - startedAt = &row.StartedAt.Time - } - if row.CompletedAt.Valid { - completedAt = &row.CompletedAt.Time - } - items[i] = domain.SubCourseWithProgress{ - SubCourseID: row.SubCourseID, - Title: row.Title, - Description: ptrText(row.Description), - Thumbnail: ptrText(row.Thumbnail), - DisplayOrder: row.DisplayOrder, - Level: row.Level, - IsActive: row.IsActive, - ProgressStatus: domain.ProgressStatus(row.ProgressStatus), - ProgressPercentage: row.ProgressPercentage, - StartedAt: startedAt, - CompletedAt: completedAt, - UnmetPrerequisitesCount: row.UnmetPrerequisitesCount, - IsLocked: row.UnmetPrerequisitesCount > 0, - } - } - return items, nil -} - -func mapUserSubCourseProgress(row dbgen.UserSubCourseProgress) domain.UserSubCourseProgress { - var startedAt, completedAt *time.Time - if row.StartedAt.Valid { - startedAt = &row.StartedAt.Time - } - if row.CompletedAt.Valid { - completedAt = &row.CompletedAt.Time - } - var updatedAt *time.Time - if row.UpdatedAt.Valid { - updatedAt = &row.UpdatedAt.Time - } - return domain.UserSubCourseProgress{ - ID: row.ID, - UserID: row.UserID, - SubCourseID: row.SubCourseID, - Status: domain.ProgressStatus(row.Status), - ProgressPercentage: row.ProgressPercentage, - StartedAt: startedAt, - CompletedAt: completedAt, - CreatedAt: row.CreatedAt.Time, - UpdatedAt: updatedAt, - } -} diff --git a/internal/repository/sub_course_videos.go b/internal/repository/sub_course_videos.go deleted file mode 100644 index 66fcf71..0000000 --- a/internal/repository/sub_course_videos.go +++ /dev/null @@ -1,329 +0,0 @@ -package repository - -import ( - dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" - "context" - "errors" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -func (s *Store) CreateSubCourseVideo( - ctx context.Context, - subCourseID int64, - title string, - description *string, - videoURL string, - duration int32, - resolution *string, - instructorID *string, - thumbnail *string, - visibility *string, - displayOrder *int32, - status *string, - vimeoID *string, - vimeoEmbedURL *string, - vimeoPlayerHTML *string, - vimeoStatus *string, - videoHostProvider *string, -) (domain.SubCourseVideo, error) { - var descText, resText, instrText, thumbText, visText, statusText pgtype.Text - var vimeoIDText, vimeoEmbedText, vimeoHTMLText, vimeoStatusText, hostProviderText pgtype.Text - - if description != nil { - descText = pgtype.Text{String: *description, Valid: true} - } - if resolution != nil { - resText = pgtype.Text{String: *resolution, Valid: true} - } - if instructorID != nil { - instrText = pgtype.Text{String: *instructorID, Valid: true} - } - if thumbnail != nil { - thumbText = pgtype.Text{String: *thumbnail, Valid: true} - } - if visibility != nil { - visText = pgtype.Text{String: *visibility, Valid: true} - } - if status != nil { - statusText = pgtype.Text{String: *status, Valid: true} - } - if vimeoID != nil { - vimeoIDText = pgtype.Text{String: *vimeoID, Valid: true} - } - if vimeoEmbedURL != nil { - vimeoEmbedText = pgtype.Text{String: *vimeoEmbedURL, Valid: true} - } - if vimeoPlayerHTML != nil { - vimeoHTMLText = pgtype.Text{String: *vimeoPlayerHTML, Valid: true} - } - if vimeoStatus != nil { - vimeoStatusText = pgtype.Text{String: *vimeoStatus, Valid: true} - } - if videoHostProvider != nil { - hostProviderText = pgtype.Text{String: *videoHostProvider, Valid: true} - } - - var dispOrder pgtype.Int4 - if displayOrder != nil { - dispOrder = pgtype.Int4{Int32: *displayOrder, Valid: true} - } - - row, err := s.queries.CreateSubCourseVideo(ctx, dbgen.CreateSubCourseVideoParams{ - SubCourseID: subCourseID, - Title: title, - Description: descText, - VideoUrl: videoURL, - Duration: duration, - Resolution: resText, - InstructorID: instrText, - Thumbnail: thumbText, - Visibility: visText, - Column10: dispOrder, - Column11: statusText, - VimeoID: vimeoIDText, - VimeoEmbedUrl: vimeoEmbedText, - VimeoPlayerHtml: vimeoHTMLText, - Column15: vimeoStatusText, - Column16: hostProviderText, - }) - if err != nil { - return domain.SubCourseVideo{}, err - } - - return mapSubCourseVideoRow(row), nil -} - -func (s *Store) GetSubCourseVideoByID( - ctx context.Context, - id int64, -) (domain.SubCourseVideo, error) { - row, err := s.queries.GetSubCourseVideoByID(ctx, id) - if err != nil { - return domain.SubCourseVideo{}, err - } - - return mapSubCourseVideoRow(row), nil -} - -func (s *Store) GetVideosBySubCourse( - ctx context.Context, - subCourseID int64, -) ([]domain.SubCourseVideo, int64, error) { - rows, err := s.queries.GetVideosBySubCourse(ctx, subCourseID) - if err != nil { - return nil, 0, err - } - - var ( - videos []domain.SubCourseVideo - totalCount int64 - ) - - for i, row := range rows { - if i == 0 { - totalCount = row.TotalCount - } - - videos = append(videos, domain.SubCourseVideo{ - ID: row.ID, - SubCourseID: row.SubCourseID, - Title: row.Title, - Description: ptrString(row.Description), - VideoURL: row.VideoUrl, - Duration: row.Duration, - Resolution: ptrString(row.Resolution), - InstructorID: ptrString(row.InstructorID), - Thumbnail: ptrString(row.Thumbnail), - Visibility: ptrString(row.Visibility), - DisplayOrder: row.DisplayOrder, - IsPublished: row.IsPublished, - PublishDate: ptrTimestamptz(row.PublishDate), - Status: row.Status, - VimeoID: ptrString(row.VimeoID), - VimeoEmbedURL: ptrString(row.VimeoEmbedUrl), - VimeoPlayerHTML: ptrString(row.VimeoPlayerHtml), - VimeoStatus: ptrString(row.VimeoStatus), - }) - } - - return videos, totalCount, nil -} - -func (s *Store) GetPublishedVideosBySubCourse( - ctx context.Context, - subCourseID int64, -) ([]domain.SubCourseVideo, error) { - rows, err := s.queries.GetPublishedVideosBySubCourse(ctx, subCourseID) - if err != nil { - return nil, err - } - - videos := make([]domain.SubCourseVideo, 0, len(rows)) - for _, row := range rows { - videos = append(videos, mapSubCourseVideoRow(row)) - } - - 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, -) error { - return s.queries.PublishSubCourseVideo(ctx, videoID) -} - -func (s *Store) UpdateSubCourseVideo( - ctx context.Context, - id int64, - title *string, - description *string, - videoURL *string, - duration *int32, - resolution *string, - visibility *string, - thumbnail *string, - displayOrder *int32, - status *string, -) error { - var titleVal, descVal, urlVal, resVal, visVal, thumbVal, statusVal string - var durationVal, dispOrderVal int32 - - if title != nil { - titleVal = *title - } - if description != nil { - descVal = *description - } - if videoURL != nil { - urlVal = *videoURL - } - if duration != nil { - durationVal = *duration - } - if resolution != nil { - resVal = *resolution - } - if visibility != nil { - visVal = *visibility - } - if thumbnail != nil { - thumbVal = *thumbnail - } - if displayOrder != nil { - dispOrderVal = *displayOrder - } - if status != nil { - statusVal = *status - } - - return s.queries.UpdateSubCourseVideo(ctx, dbgen.UpdateSubCourseVideoParams{ - Title: titleVal, - Description: pgtype.Text{String: descVal, Valid: description != nil}, - VideoUrl: urlVal, - Duration: durationVal, - Resolution: pgtype.Text{String: resVal, Valid: resolution != nil}, - Visibility: pgtype.Text{String: visVal, Valid: visibility != nil}, - Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil}, - DisplayOrder: dispOrderVal, - Status: statusVal, - ID: id, - }) -} - -func (s *Store) ArchiveSubCourseVideo( - ctx context.Context, - id int64, -) error { - return s.queries.ArchiveSubCourseVideo(ctx, id) -} - -func (s *Store) DeleteSubCourseVideo( - ctx context.Context, - id int64, -) error { - return s.queries.DeleteSubCourseVideo(ctx, id) -} - -func mapSubCourseVideoRow(row dbgen.SubCourseVideo) domain.SubCourseVideo { - return domain.SubCourseVideo{ - ID: row.ID, - SubCourseID: row.SubCourseID, - Title: row.Title, - Description: ptrString(row.Description), - VideoURL: row.VideoUrl, - Duration: row.Duration, - Resolution: ptrString(row.Resolution), - InstructorID: ptrString(row.InstructorID), - Thumbnail: ptrString(row.Thumbnail), - Visibility: ptrString(row.Visibility), - DisplayOrder: row.DisplayOrder, - IsPublished: row.IsPublished, - PublishDate: ptrTimestamptz(row.PublishDate), - Status: row.Status, - VimeoID: ptrString(row.VimeoID), - VimeoEmbedURL: ptrString(row.VimeoEmbedUrl), - VimeoPlayerHTML: ptrString(row.VimeoPlayerHtml), - VimeoStatus: ptrString(row.VimeoStatus), - } -} - -func (s *Store) UpdateVimeoStatus(ctx context.Context, videoID int64, status string) error { - return s.queries.UpdateVimeoStatus(ctx, dbgen.UpdateVimeoStatusParams{ - VimeoStatus: pgtype.Text{String: status, Valid: true}, - ID: videoID, - }) -} - -func (s *Store) GetVideoByVimeoID(ctx context.Context, vimeoID string) (domain.SubCourseVideo, error) { - row, err := s.queries.GetVideosByVimeoID(ctx, pgtype.Text{String: vimeoID, Valid: true}) - if err != nil { - return domain.SubCourseVideo{}, err - } - return mapSubCourseVideoRow(row), nil -} - -func (s *Store) ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error { - return s.queries.ReorderSubCourseVideos(ctx, dbgen.ReorderSubCourseVideosParams{ - Ids: ids, - Positions: positions, - }) -} diff --git a/internal/repository/sub_courses.go b/internal/repository/sub_courses.go deleted file mode 100644 index fd031d6..0000000 --- a/internal/repository/sub_courses.go +++ /dev/null @@ -1,256 +0,0 @@ -package repository - -import ( - dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -func (s *Store) CreateSubCourse( - ctx context.Context, - courseID int64, - title string, - description *string, - thumbnail *string, - displayOrder *int32, - level string, - subLevel string, -) (domain.SubCourse, error) { - var descText, thumbText pgtype.Text - if description != nil { - descText = pgtype.Text{String: *description, Valid: true} - } - if thumbnail != nil { - thumbText = pgtype.Text{String: *thumbnail, Valid: true} - } - - var dispOrder pgtype.Int4 - if displayOrder != nil { - dispOrder = pgtype.Int4{Int32: *displayOrder, Valid: true} - } - - row, err := s.queries.CreateSubCourse(ctx, dbgen.CreateSubCourseParams{ - CourseID: courseID, - Title: title, - Description: descText, - Thumbnail: thumbText, - Column5: dispOrder, - Level: level, - SubLevel: subLevel, - Column8: pgtype.Bool{Bool: true, Valid: true}, - }) - if err != nil { - return domain.SubCourse{}, err - } - - return domain.SubCourse{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: ptrString(row.Description), - Thumbnail: ptrString(row.Thumbnail), - DisplayOrder: row.DisplayOrder, - Level: row.Level, - SubLevel: row.SubLevel, - IsActive: row.IsActive, - }, nil -} - -func (s *Store) GetSubCourseByID( - ctx context.Context, - id int64, -) (domain.SubCourse, error) { - row, err := s.queries.GetSubCourseByID(ctx, id) - if err != nil { - return domain.SubCourse{}, err - } - - return domain.SubCourse{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: ptrString(row.Description), - Thumbnail: ptrString(row.Thumbnail), - DisplayOrder: row.DisplayOrder, - Level: row.Level, - SubLevel: row.SubLevel, - IsActive: row.IsActive, - }, nil -} - -func (s *Store) GetSubCoursesByCourse( - ctx context.Context, - courseID int64, -) ([]domain.SubCourse, int64, error) { - rows, err := s.queries.GetSubCoursesByCourse(ctx, courseID) - if err != nil { - return nil, 0, err - } - - var ( - subCourses []domain.SubCourse - totalCount int64 - ) - - for i, row := range rows { - if i == 0 { - totalCount = row.TotalCount - } - - subCourses = append(subCourses, domain.SubCourse{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: ptrString(row.Description), - Thumbnail: ptrString(row.Thumbnail), - DisplayOrder: row.DisplayOrder, - Level: row.Level, - SubLevel: row.SubLevel, - IsActive: row.IsActive, - }) - } - - return subCourses, totalCount, nil -} - -func (s *Store) ListSubCoursesByCourse( - ctx context.Context, - courseID int64, -) ([]domain.SubCourse, error) { - rows, err := s.queries.ListSubCoursesByCourse(ctx, courseID) - if err != nil { - return nil, err - } - - subCourses := make([]domain.SubCourse, 0, len(rows)) - for _, row := range rows { - subCourses = append(subCourses, domain.SubCourse{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: ptrString(row.Description), - Thumbnail: ptrString(row.Thumbnail), - DisplayOrder: row.DisplayOrder, - Level: row.Level, - SubLevel: row.SubLevel, - IsActive: row.IsActive, - }) - } - - return subCourses, nil -} - -func (s *Store) ListActiveSubCourses( - ctx context.Context, -) ([]domain.SubCourse, error) { - rows, err := s.queries.ListActiveSubCourses(ctx) - if err != nil { - return nil, err - } - - subCourses := make([]domain.SubCourse, 0, len(rows)) - for _, row := range rows { - subCourses = append(subCourses, domain.SubCourse{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: ptrString(row.Description), - Thumbnail: ptrString(row.Thumbnail), - DisplayOrder: row.DisplayOrder, - Level: row.Level, - SubLevel: row.SubLevel, - IsActive: row.IsActive, - }) - } - - return subCourses, nil -} - -func (s *Store) UpdateSubCourse( - ctx context.Context, - id int64, - title *string, - description *string, - thumbnail *string, - displayOrder *int32, - level *string, - subLevel *string, - isActive *bool, -) error { - var titleVal, descVal, thumbVal, levelVal, subLevelVal string - var dispOrderVal int32 - var isActiveVal bool - - if title != nil { - titleVal = *title - } - if description != nil { - descVal = *description - } - if thumbnail != nil { - thumbVal = *thumbnail - } - if displayOrder != nil { - dispOrderVal = *displayOrder - } - if level != nil { - levelVal = *level - } - if subLevel != nil { - subLevelVal = *subLevel - } - if isActive != nil { - isActiveVal = *isActive - } - - return s.queries.UpdateSubCourse(ctx, dbgen.UpdateSubCourseParams{ - Title: titleVal, - Description: pgtype.Text{String: descVal, Valid: description != nil}, - Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil}, - DisplayOrder: dispOrderVal, - Level: levelVal, - SubLevel: subLevelVal, - IsActive: isActiveVal, - ID: id, - }) -} - -func (s *Store) DeactivateSubCourse( - ctx context.Context, - id int64, -) error { - return s.queries.DeactivateSubCourse(ctx, id) -} - -func (s *Store) DeleteSubCourse( - ctx context.Context, - id int64, -) (domain.SubCourse, error) { - row, err := s.queries.DeleteSubCourse(ctx, id) - if err != nil { - return domain.SubCourse{}, err - } - - return domain.SubCourse{ - ID: row.ID, - CourseID: row.CourseID, - Title: row.Title, - Description: ptrString(row.Description), - Thumbnail: ptrString(row.Thumbnail), - DisplayOrder: row.DisplayOrder, - Level: row.Level, - SubLevel: row.SubLevel, - IsActive: row.IsActive, - }, nil -} - -func (s *Store) ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error { - return s.queries.ReorderSubCourses(ctx, dbgen.ReorderSubCoursesParams{ - Ids: ids, - Positions: positions, - }) -} - - diff --git a/internal/services/course_management/course_catagories.go b/internal/services/course_management/course_catagories.go deleted file mode 100644 index 776fcd5..0000000 --- a/internal/services/course_management/course_catagories.go +++ /dev/null @@ -1,44 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/domain" - "context" -) - -func (s *Service) CreateCourseCategory( - ctx context.Context, - name string, -) (domain.CourseCategory, error) { - return s.courseStore.CreateCourseCategory(ctx, name) -} - -func (s *Service) GetCourseCategoryByID( - ctx context.Context, - id int64, -) (domain.CourseCategory, error) { - return s.courseStore.GetCourseCategoryByID(ctx, id) -} - -func (s *Service) GetAllCourseCategories( - ctx context.Context, - limit int32, - offset int32, -) ([]domain.CourseCategory, int64, error) { - return s.courseStore.GetAllCourseCategories(ctx, limit, offset) -} - -func (s *Service) UpdateCourseCategory( - ctx context.Context, - id int64, - name *string, - isActive *bool, -) error { - return s.courseStore.UpdateCourseCategory(ctx, id, name, isActive) -} - -func (s *Service) DeleteCourseCategory( - ctx context.Context, - id int64, -) error { - return s.courseStore.DeleteCourseCategory(ctx, id) -} diff --git a/internal/services/course_management/courses.go b/internal/services/course_management/courses.go deleted file mode 100644 index ad18f18..0000000 --- a/internal/services/course_management/courses.go +++ /dev/null @@ -1,52 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/domain" - "context" -) - -func (s *Service) CreateCourse( - ctx context.Context, - categoryID int64, - title string, - description *string, - thumbnail *string, - introVideoURL *string, -) (domain.Course, error) { - return s.courseStore.CreateCourse(ctx, categoryID, title, description, thumbnail, introVideoURL) -} - -func (s *Service) GetCourseByID( - ctx context.Context, - id int64, -) (domain.Course, error) { - return s.courseStore.GetCourseByID(ctx, id) -} - -func (s *Service) GetCoursesByCategory( - ctx context.Context, - categoryID int64, - limit int32, - offset int32, -) ([]domain.Course, int64, error) { - return s.courseStore.GetCoursesByCategory(ctx, categoryID, limit, offset) -} - -func (s *Service) UpdateCourse( - ctx context.Context, - id int64, - title *string, - description *string, - thumbnail *string, - introVideoURL *string, - isActive *bool, -) error { - return s.courseStore.UpdateCourse(ctx, id, title, description, thumbnail, introVideoURL, isActive) -} - -func (s *Service) DeleteCourse( - ctx context.Context, - id int64, -) error { - return s.courseStore.DeleteCourse(ctx, id) -} diff --git a/internal/services/course_management/learning_tree.go b/internal/services/course_management/learning_tree.go deleted file mode 100644 index 5b9850c..0000000 --- a/internal/services/course_management/learning_tree.go +++ /dev/null @@ -1,34 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/domain" - "context" -) - -func (s *Service) GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) { - return s.courseStore.GetFullLearningTree(ctx) -} - -func (s *Service) GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) { - return s.courseStore.GetCourseLearningPath(ctx, courseID) -} - -func (s *Service) ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error { - return s.courseStore.ReorderCourseCategories(ctx, ids, positions) -} - -func (s *Service) ReorderCourses(ctx context.Context, ids []int64, positions []int32) error { - return s.courseStore.ReorderCourses(ctx, ids, positions) -} - -func (s *Service) ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error { - return s.courseStore.ReorderSubCourses(ctx, ids, positions) -} - -func (s *Service) ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error { - return s.courseStore.ReorderSubCourseVideos(ctx, ids, positions) -} - -func (s *Service) ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error { - return s.courseStore.ReorderQuestionSets(ctx, ids, positions) -} diff --git a/internal/services/course_management/progression.go b/internal/services/course_management/progression.go deleted file mode 100644 index 194d94a..0000000 --- a/internal/services/course_management/progression.go +++ /dev/null @@ -1,73 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/domain" - "context" -) - -// --- Prerequisites (admin) --- - -func (s *Service) AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error { - if subCourseID == prerequisiteSubCourseID { - return domain.ErrSelfPrerequisite - } - return s.progressionStore.AddSubCoursePrerequisite(ctx, subCourseID, prerequisiteSubCourseID) -} - -func (s *Service) RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error { - return s.progressionStore.RemoveSubCoursePrerequisite(ctx, subCourseID, prerequisiteSubCourseID) -} - -func (s *Service) GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error) { - return s.progressionStore.GetSubCoursePrerequisites(ctx, subCourseID) -} - -func (s *Service) GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error) { - return s.progressionStore.GetSubCourseDependents(ctx, prerequisiteSubCourseID) -} - -// --- User progress --- - -func (s *Service) CheckSubCourseAccess(ctx context.Context, userID, subCourseID int64) (bool, error) { - unmet, err := s.progressionStore.CountUnmetPrerequisites(ctx, subCourseID, userID) - if err != nil { - return false, err - } - return unmet == 0, nil -} - -func (s *Service) StartSubCourse(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) { - accessible, err := s.CheckSubCourseAccess(ctx, userID, subCourseID) - if err != nil { - return domain.UserSubCourseProgress{}, err - } - if !accessible { - return domain.UserSubCourseProgress{}, domain.ErrPrerequisiteNotMet - } - - return s.progressionStore.StartSubCourseProgress(ctx, userID, subCourseID) -} - -func (s *Service) UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error { - return s.progressionStore.UpdateSubCourseProgress(ctx, userID, subCourseID, percentage) -} - -func (s *Service) CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error { - return s.progressionStore.CompleteSubCourse(ctx, userID, subCourseID) -} - -func (s *Service) RecalculateSubCourseProgress(ctx context.Context, userID, subCourseID int64) error { - return s.progressionStore.RecalculateSubCourseProgress(ctx, userID, subCourseID) -} - -func (s *Service) GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) { - return s.progressionStore.GetUserSubCourseProgress(ctx, userID, subCourseID) -} - -func (s *Service) GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error) { - return s.progressionStore.GetUserCourseProgress(ctx, userID, courseID) -} - -func (s *Service) GetSubCoursesWithProgress(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error) { - return s.progressionStore.GetSubCoursesWithProgressByCourse(ctx, userID, courseID) -} diff --git a/internal/services/course_management/service.go b/internal/services/course_management/service.go index 63422ca..d047a7a 100644 --- a/internal/services/course_management/service.go +++ b/internal/services/course_management/service.go @@ -10,8 +10,8 @@ import ( type Service struct { userStore ports.UserStore - courseStore ports.CourseStore - progressionStore ports.ProgressionStore + courseStore interface{} + progressionStore interface{} notificationSvc *notificationservice.Service vimeoSvc *vimeoservice.Service cloudConvertSvc *cloudconvertservice.Service @@ -20,8 +20,8 @@ type Service struct { func NewService( userStore ports.UserStore, - courseStore ports.CourseStore, - progressionStore ports.ProgressionStore, + courseStore interface{}, + progressionStore interface{}, notificationSvc *notificationservice.Service, cfg *config.Config, ) *Service { diff --git a/internal/services/course_management/sub_course_videos.go b/internal/services/course_management/sub_course_videos.go deleted file mode 100644 index 1a25b7b..0000000 --- a/internal/services/course_management/sub_course_videos.go +++ /dev/null @@ -1,299 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/domain" - "Yimaru-Backend/internal/pkgs/vimeo" - vimeoservice "Yimaru-Backend/internal/services/vimeo" - "context" - "fmt" - "io" - "net/http" - "path" - "time" -) - -func (s *Service) CreateSubCourseVideo( - ctx context.Context, - subCourseID int64, - title string, - description *string, - videoURL string, - duration int32, - resolution *string, - instructorID *string, - thumbnail *string, - visibility *string, - displayOrder *int32, - status *string, -) (domain.SubCourseVideo, error) { - // Default to DIRECT provider when no Vimeo info provided - provider := string(domain.VideoHostProviderDirect) - return s.courseStore.CreateSubCourseVideo( - ctx, subCourseID, title, description, videoURL, duration, - resolution, instructorID, thumbnail, visibility, displayOrder, status, - nil, nil, nil, nil, &provider, - ) -} - -// CreateSubCourseVideoWithVimeo creates a video and uploads it to Vimeo -func (s *Service) CreateSubCourseVideoWithVimeo( - ctx context.Context, - subCourseID int64, - title string, - description *string, - sourceURL string, - fileSize int64, - duration int32, - resolution *string, - instructorID *string, - thumbnail *string, - visibility *string, - displayOrder *int32, -) (domain.SubCourseVideo, error) { - if s.vimeoSvc == nil { - return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured") - } - - descStr := "" - if description != nil { - descStr = *description - } - - var uploadResult *vimeoservice.UploadResult - var err error - - if s.cloudConvertSvc != nil { - httpClient := &http.Client{Timeout: 30 * time.Minute} - req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) - if reqErr != nil { - return domain.SubCourseVideo{}, fmt.Errorf("failed to create download request: %w", reqErr) - } - resp, dlErr := httpClient.Do(req) - if dlErr != nil { - return domain.SubCourseVideo{}, fmt.Errorf("failed to download source video: %w", dlErr) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return domain.SubCourseVideo{}, fmt.Errorf("failed to download source video: status %d", resp.StatusCode) - } - - dlSize := resp.ContentLength - if dlSize <= 0 { - dlSize = fileSize - } - - filename := path.Base(sourceURL) - if filename == "" || filename == "." || filename == "/" { - filename = "video.mp4" - } - - result, compErr := s.cloudConvertSvc.CompressVideo(ctx, filename, resp.Body, dlSize) - if compErr != nil { - return domain.SubCourseVideo{}, fmt.Errorf("failed to compress video: %w", compErr) - } - defer result.Data.Close() - - uploadResult, err = s.vimeoSvc.UploadVideoFile(ctx, title, descStr, result.Data, result.FileSize) - } else { - uploadResult, err = s.vimeoSvc.CreatePullUpload(ctx, title, descStr, sourceURL, fileSize) - } - - if err != nil { - return domain.SubCourseVideo{}, fmt.Errorf("failed to upload to Vimeo: %w", err) - } - - embedURL := vimeo.GenerateEmbedURL(uploadResult.VimeoID, &vimeo.EmbedOptions{ - Title: true, - Byline: true, - Portrait: true, - }) - - embedHTML := vimeo.GenerateIframeEmbed(uploadResult.VimeoID, 640, 360, nil) - - provider := string(domain.VideoHostProviderVimeo) - vimeoStatus := "uploading" - status := "DRAFT" - - return s.courseStore.CreateSubCourseVideo( - ctx, subCourseID, title, description, - uploadResult.Link, - duration, resolution, instructorID, thumbnail, visibility, displayOrder, &status, - &uploadResult.VimeoID, &embedURL, &embedHTML, &vimeoStatus, &provider, - ) -} - -func (s *Service) CreateSubCourseVideoWithFileUpload( - ctx context.Context, - subCourseID int64, - title string, - description *string, - filename string, - fileData io.Reader, - fileSize int64, - duration int32, - resolution *string, - instructorID *string, - thumbnail *string, - visibility *string, - displayOrder *int32, -) (domain.SubCourseVideo, error) { - if s.vimeoSvc == nil { - return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured") - } - - descStr := "" - if description != nil { - descStr = *description - } - - videoReader := fileData - videoSize := fileSize - - if s.cloudConvertSvc != nil { - result, err := s.cloudConvertSvc.CompressVideo(ctx, filename, fileData, fileSize) - if err != nil { - return domain.SubCourseVideo{}, fmt.Errorf("failed to compress video: %w", err) - } - defer result.Data.Close() - videoReader = result.Data - videoSize = result.FileSize - } - - uploadResult, err := s.vimeoSvc.UploadVideoFile(ctx, title, descStr, videoReader, videoSize) - if err != nil { - return domain.SubCourseVideo{}, fmt.Errorf("failed to upload video file to Vimeo: %w", err) - } - - embedURL := vimeo.GenerateEmbedURL(uploadResult.VimeoID, &vimeo.EmbedOptions{ - Title: true, - Byline: true, - Portrait: true, - }) - - embedHTML := vimeo.GenerateIframeEmbed(uploadResult.VimeoID, 640, 360, nil) - - provider := string(domain.VideoHostProviderVimeo) - vimeoStatus := "uploading" - status := "DRAFT" - - return s.courseStore.CreateSubCourseVideo( - ctx, subCourseID, title, description, - uploadResult.Link, - duration, resolution, instructorID, thumbnail, visibility, displayOrder, &status, - &uploadResult.VimeoID, &embedURL, &embedHTML, &vimeoStatus, &provider, - ) -} - -// CreateSubCourseVideoFromVimeoID creates a video record from an existing Vimeo video -func (s *Service) CreateSubCourseVideoFromVimeoID( - ctx context.Context, - subCourseID int64, - vimeoVideoID string, - title string, - description *string, - displayOrder *int32, - instructorID *string, -) (domain.SubCourseVideo, error) { - if s.vimeoSvc == nil { - return domain.SubCourseVideo{}, fmt.Errorf("vimeo service is not configured") - } - - // Fetch video info from Vimeo - info, err := s.vimeoSvc.GetVideoInfo(ctx, vimeoVideoID) - if err != nil { - return domain.SubCourseVideo{}, fmt.Errorf("failed to get Vimeo video info: %w", err) - } - - // Use Vimeo data - embedHTML := vimeo.GenerateIframeEmbed(vimeoVideoID, 640, 360, nil) - provider := string(domain.VideoHostProviderVimeo) - vimeoStatus := info.TranscodeStatus - if vimeoStatus == "" { - vimeoStatus = "available" - } - status := "DRAFT" - duration := int32(info.Duration) - resolution := fmt.Sprintf("%dx%d", info.Width, info.Height) - thumbnail := info.ThumbnailURL - - return s.courseStore.CreateSubCourseVideo( - ctx, subCourseID, title, description, - info.Link, duration, &resolution, instructorID, &thumbnail, nil, displayOrder, &status, - &vimeoVideoID, &info.EmbedURL, &embedHTML, &vimeoStatus, &provider, - ) -} - -func (s *Service) GetSubCourseVideoByID( - ctx context.Context, - id int64, -) (domain.SubCourseVideo, error) { - return s.courseStore.GetSubCourseVideoByID(ctx, id) -} - -func (s *Service) GetVideosBySubCourse( - ctx context.Context, - subCourseID int64, -) ([]domain.SubCourseVideo, int64, error) { - return s.courseStore.GetVideosBySubCourse(ctx, subCourseID) -} - -func (s *Service) GetPublishedVideosBySubCourse( - ctx context.Context, - subCourseID int64, -) ([]domain.SubCourseVideo, error) { - 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, -) error { - return s.courseStore.PublishSubCourseVideo(ctx, videoID) -} - -func (s *Service) UpdateSubCourseVideo( - ctx context.Context, - id int64, - title *string, - description *string, - videoURL *string, - duration *int32, - resolution *string, - visibility *string, - thumbnail *string, - displayOrder *int32, - status *string, -) error { - return s.courseStore.UpdateSubCourseVideo(ctx, id, title, description, videoURL, duration, resolution, visibility, thumbnail, displayOrder, status) -} - -func (s *Service) ArchiveSubCourseVideo( - ctx context.Context, - id int64, -) error { - return s.courseStore.ArchiveSubCourseVideo(ctx, id) -} - -func (s *Service) DeleteSubCourseVideo( - ctx context.Context, - id int64, -) error { - return s.courseStore.DeleteSubCourseVideo(ctx, id) -} diff --git a/internal/services/course_management/sub_courses.go b/internal/services/course_management/sub_courses.go deleted file mode 100644 index 9bf8545..0000000 --- a/internal/services/course_management/sub_courses.go +++ /dev/null @@ -1,74 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/domain" - "context" -) - -func (s *Service) CreateSubCourse( - ctx context.Context, - courseID int64, - title string, - description *string, - thumbnail *string, - displayOrder *int32, - level string, - subLevel string, -) (domain.SubCourse, error) { - return s.courseStore.CreateSubCourse(ctx, courseID, title, description, thumbnail, displayOrder, level, subLevel) -} - -func (s *Service) GetSubCourseByID( - ctx context.Context, - id int64, -) (domain.SubCourse, error) { - return s.courseStore.GetSubCourseByID(ctx, id) -} - -func (s *Service) GetSubCoursesByCourse( - ctx context.Context, - courseID int64, -) ([]domain.SubCourse, int64, error) { - return s.courseStore.GetSubCoursesByCourse(ctx, courseID) -} - -func (s *Service) ListSubCoursesByCourse( - ctx context.Context, - courseID int64, -) ([]domain.SubCourse, error) { - return s.courseStore.ListSubCoursesByCourse(ctx, courseID) -} - -func (s *Service) ListActiveSubCourses( - ctx context.Context, -) ([]domain.SubCourse, error) { - return s.courseStore.ListActiveSubCourses(ctx) -} - -func (s *Service) UpdateSubCourse( - ctx context.Context, - id int64, - title *string, - description *string, - thumbnail *string, - displayOrder *int32, - level *string, - subLevel *string, - isActive *bool, -) error { - return s.courseStore.UpdateSubCourse(ctx, id, title, description, thumbnail, displayOrder, level, subLevel, isActive) -} - -func (s *Service) DeactivateSubCourse( - ctx context.Context, - id int64, -) error { - return s.courseStore.DeactivateSubCourse(ctx, id) -} - -func (s *Service) DeleteSubCourse( - ctx context.Context, - id int64, -) (domain.SubCourse, error) { - return s.courseStore.DeleteSubCourse(ctx, id) -} diff --git a/internal/web_server/handlers/course_management.go b/internal/web_server/handlers/course_management.go deleted file mode 100644 index 0f85dd1..0000000 --- a/internal/web_server/handlers/course_management.go +++ /dev/null @@ -1,2872 +0,0 @@ -package handlers - -import ( - "Yimaru-Backend/internal/domain" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - - "github.com/gofiber/fiber/v2" - "github.com/google/uuid" - "go.uber.org/zap" -) - -var humanLanguageModulePattern = regexp.MustCompile(`(?i)^module-(\d+)(?:\.(\d+))?$`) - -// Course Category Handlers - -type createCourseCategoryReq struct { - Name string `json:"name" validate:"required"` -} - -type courseCategoryRes struct { - ID int64 `json:"id"` - Name string `json:"name"` - IsActive bool `json:"is_active"` - CreatedAt string `json:"created_at"` -} - -// CreateCourseCategory godoc -// @Summary Create a new course category -// @Description Creates a new course category with the provided name -// @Tags course-categories -// @Accept json -// @Produce json -// @Param body body createCourseCategoryReq true "Create category payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/categories [post] -func (h *Handler) CreateCourseCategory(c *fiber.Ctx) error { - var req createCourseCategoryReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - category, err := h.courseMgmtSvc.CreateCourseCategory(c.Context(), req.Name) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to create course category", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"name": category.Name}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryCreated, domain.ResourceCategory, &category.ID, "Created course category: "+category.Name, meta, &ip, &ua) - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Course category created successfully", - Data: courseCategoryRes{ - ID: category.ID, - Name: category.Name, - IsActive: category.IsActive, - CreatedAt: category.CreatedAt.String(), - }, - }) -} - -// GetCourseCategoryByID godoc -// @Summary Get course category by ID -// @Description Returns a single course category by its ID -// @Tags course-categories -// @Produce json -// @Param id path int true "Category ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 404 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/categories/{id} [get] -func (h *Handler) GetCourseCategoryByID(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid category ID", - Error: err.Error(), - }) - } - - category, err := h.courseMgmtSvc.GetCourseCategoryByID(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Course category not found", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Course category retrieved successfully", - Data: courseCategoryRes{ - ID: category.ID, - Name: category.Name, - IsActive: category.IsActive, - CreatedAt: category.CreatedAt.String(), - }, - }) -} - -type getAllCourseCategoriesRes struct { - Categories []courseCategoryRes `json:"categories"` - TotalCount int64 `json:"total_count"` -} - -// GetAllCourseCategories godoc -// @Summary Get all course categories -// @Description Returns a paginated list of all course categories -// @Tags course-categories -// @Produce json -// @Param limit query int false "Limit" default(10) -// @Param offset query int false "Offset" default(0) -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/categories [get] -func (h *Handler) GetAllCourseCategories(c *fiber.Ctx) error { - limitStr := c.Query("limit", "10") - offsetStr := c.Query("offset", "0") - - limit, err := strconv.Atoi(limitStr) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid limit parameter", - Error: err.Error(), - }) - } - - offset, err := strconv.Atoi(offsetStr) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid offset parameter", - Error: err.Error(), - }) - } - - categories, totalCount, err := h.courseMgmtSvc.GetAllCourseCategories(c.Context(), int32(limit), int32(offset)) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve course categories", - Error: err.Error(), - }) - } - - var categoryResponses []courseCategoryRes - for _, category := range categories { - categoryResponses = append(categoryResponses, courseCategoryRes{ - ID: category.ID, - Name: category.Name, - IsActive: category.IsActive, - CreatedAt: category.CreatedAt.String(), - }) - } - - return c.JSON(domain.Response{ - Message: "Course categories retrieved successfully", - Data: getAllCourseCategoriesRes{ - Categories: categoryResponses, - TotalCount: totalCount, - }, - }) -} - -type updateCourseCategoryReq struct { - Name *string `json:"name"` - IsActive *bool `json:"is_active"` -} - -// UpdateCourseCategory godoc -// @Summary Update course category -// @Description Updates a course category's name and/or active status -// @Tags course-categories -// @Accept json -// @Produce json -// @Param id path int true "Category ID" -// @Param body body updateCourseCategoryReq true "Update category payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/categories/{id} [put] -func (h *Handler) UpdateCourseCategory(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid category ID", - Error: err.Error(), - }) - } - - var req updateCourseCategoryReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.UpdateCourseCategory(c.Context(), id, req.Name, req.IsActive) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update course category", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name, "is_active": req.IsActive}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryUpdated, domain.ResourceCategory, &id, fmt.Sprintf("Updated course category ID: %d", id), meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Course category updated successfully", - }) -} - -// DeleteCourseCategory godoc -// @Summary Delete course category -// @Description Deletes a course category by its ID -// @Tags course-categories -// @Produce json -// @Param id path int true "Category ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/categories/{id} [delete] -func (h *Handler) DeleteCourseCategory(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid category ID", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.DeleteCourseCategory(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to delete course category", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"id": id}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryDeleted, domain.ResourceCategory, &id, fmt.Sprintf("Deleted category ID: %d", id), meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Course category deleted successfully", - }) -} - -// Course Handlers - -type createCourseReq struct { - CategoryID int64 `json:"category_id" validate:"required"` - Title string `json:"title" validate:"required"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - IntroVideoURL *string `json:"intro_video_url"` -} - -type courseRes struct { - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - Title string `json:"title"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - IntroVideoURL *string `json:"intro_video_url,omitempty"` - IsActive bool `json:"is_active"` -} - -// CreateCourse godoc -// @Summary Create a new course -// @Description Creates a new course under a specific category -// @Tags courses -// @Accept json -// @Produce json -// @Param body body createCourseReq true "Create course payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/courses [post] -func (h *Handler) CreateCourse(c *fiber.Ctx) error { - var req createCourseReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - course, err := h.courseMgmtSvc.CreateCourse(c.Context(), req.CategoryID, req.Title, req.Description, req.Thumbnail, req.IntroVideoURL) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to create course", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"title": course.Title, "category_id": course.CategoryID}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseCreated, domain.ResourceCourse, &course.ID, "Created course: "+course.Title, meta, &ip, &ua) - - go func() { - students, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: string(domain.RoleStudent)}) - if err != nil { - return - } - for _, s := range students { - h.sendInAppNotification(s.ID, domain.NOTIFICATION_TYPE_COURSE_CREATED, "New Course Available", "A new course \""+course.Title+"\" has been added. Check it out!") - } - }() - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Course created successfully", - Data: courseRes{ - ID: course.ID, - CategoryID: course.CategoryID, - Title: course.Title, - Description: course.Description, - Thumbnail: course.Thumbnail, - IntroVideoURL: course.IntroVideoURL, - IsActive: course.IsActive, - }, - }) -} - -// GetCourseByID godoc -// @Summary Get course by ID -// @Description Returns a single course by its ID -// @Tags courses -// @Produce json -// @Param id path int true "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/course-management/courses/{id} [get] -func (h *Handler) GetCourseByID(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid course ID", - Error: err.Error(), - }) - } - - course, err := h.courseMgmtSvc.GetCourseByID(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Course not found", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Course retrieved successfully", - Data: courseRes{ - ID: course.ID, - CategoryID: course.CategoryID, - Title: course.Title, - Description: course.Description, - Thumbnail: course.Thumbnail, - IntroVideoURL: course.IntroVideoURL, - IsActive: course.IsActive, - }, - }) -} - -type getCoursesByCategoryRes struct { - Courses []courseRes `json:"courses"` - TotalCount int64 `json:"total_count"` -} - -// GetCoursesByCategory godoc -// @Summary Get courses by category -// @Description Returns a paginated list of courses under a specific category -// @Tags courses -// @Produce json -// @Param categoryId path int true "Category ID" -// @Param limit query int false "Limit" default(10) -// @Param offset query int false "Offset" default(0) -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/categories/{categoryId}/courses [get] -func (h *Handler) GetCoursesByCategory(c *fiber.Ctx) error { - categoryIDStr := c.Params("categoryId") - categoryID, err := strconv.ParseInt(categoryIDStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid category ID", - Error: err.Error(), - }) - } - - limitStr := c.Query("limit", "10") - offsetStr := c.Query("offset", "0") - - limit, err := strconv.Atoi(limitStr) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid limit parameter", - Error: err.Error(), - }) - } - - offset, err := strconv.Atoi(offsetStr) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid offset parameter", - Error: err.Error(), - }) - } - - courses, totalCount, err := h.courseMgmtSvc.GetCoursesByCategory(c.Context(), categoryID, int32(limit), int32(offset)) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve courses", - Error: err.Error(), - }) - } - - var courseResponses []courseRes - for _, course := range courses { - courseResponses = append(courseResponses, courseRes{ - ID: course.ID, - CategoryID: course.CategoryID, - Title: course.Title, - Description: course.Description, - Thumbnail: course.Thumbnail, - IntroVideoURL: course.IntroVideoURL, - IsActive: course.IsActive, - }) - } - - return c.JSON(domain.Response{ - Message: "Courses retrieved successfully", - Data: getCoursesByCategoryRes{ - Courses: courseResponses, - TotalCount: totalCount, - }, - }) -} - -type updateCourseReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - IntroVideoURL *string `json:"intro_video_url"` - IsActive *bool `json:"is_active"` -} - -// UpdateCourse godoc -// @Summary Update course -// @Description Updates a course's title, description, and/or active status -// @Tags courses -// @Accept json -// @Produce json -// @Param id path int true "Course ID" -// @Param body body updateCourseReq true "Update course payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/courses/{id} [put] -func (h *Handler) UpdateCourse(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid course ID", - Error: err.Error(), - }) - } - - var req updateCourseReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.UpdateCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.IntroVideoURL, req.IsActive) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update course", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - 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, "thumbnail": req.Thumbnail, "is_active": req.IsActive}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceCourse, &id, fmt.Sprintf("Updated course ID: %d", id), meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Course updated successfully", - }) -} - -// DeleteCourse godoc -// @Summary Delete course -// @Description Deletes a course by its ID -// @Tags courses -// @Produce json -// @Param id path int true "Course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/courses/{id} [delete] -func (h *Handler) DeleteCourse(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid course ID", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.DeleteCourse(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to delete course", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"id": id}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseDeleted, domain.ResourceCourse, &id, fmt.Sprintf("Deleted course ID: %d", id), meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Course deleted successfully", - }) -} - -// Sub-course Handlers - -type createSubCourseReq struct { - CourseID int64 `json:"course_id" validate:"required"` - Title string `json:"title" validate:"required"` - Description *string `json:"description"` - 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 { - ID int64 `json:"id"` - CourseID int64 `json:"course_id"` - Title string `json:"title"` - Description *string `json:"description"` - 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 - } -} - -func isValidHumanLanguageCEFRLevel(level string) bool { - switch strings.ToUpper(strings.TrimSpace(level)) { - case string(domain.SubCourseSubLevelA1), - string(domain.SubCourseSubLevelA2), - string(domain.SubCourseSubLevelA3), - string(domain.SubCourseSubLevelB1), - string(domain.SubCourseSubLevelB2), - string(domain.SubCourseSubLevelB3), - string(domain.SubCourseSubLevelC1), - string(domain.SubCourseSubLevelC2), - string(domain.SubCourseSubLevelC3): - return true - default: - return false - } -} - -func coarseLevelFromCEFR(cefr string) string { - switch strings.ToUpper(strings.TrimSpace(cefr)) { - case string(domain.SubCourseSubLevelA1), string(domain.SubCourseSubLevelA2), string(domain.SubCourseSubLevelA3): - return string(domain.SubCourseLevelBeginner) - case string(domain.SubCourseSubLevelB1), string(domain.SubCourseSubLevelB2), string(domain.SubCourseSubLevelB3): - return string(domain.SubCourseLevelIntermediate) - default: - return string(domain.SubCourseLevelAdvanced) - } -} - -func (h *Handler) ensureCourseIsHumanLanguage(ctx context.Context, courseID int64) (domain.Course, error) { - course, err := h.courseMgmtSvc.GetCourseByID(ctx, courseID) - if err != nil { - return domain.Course{}, err - } - category, err := h.courseMgmtSvc.GetCourseCategoryByID(ctx, course.CategoryID) - if err != nil { - return domain.Course{}, err - } - categoryName := strings.ToLower(strings.TrimSpace(category.Name)) - if !strings.Contains(categoryName, "language") { - return domain.Course{}, fmt.Errorf("course is not under a human language category") - } - return course, nil -} - -type createHumanLanguageLessonReq struct { - CourseID int64 `json:"course_id" validate:"required"` - Title string `json:"title" validate:"required"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - DisplayOrder *int32 `json:"display_order"` - CEFRLevel string `json:"cefr_level" validate:"required"` -} - -type updateHumanLanguageLessonReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - DisplayOrder *int32 `json:"display_order"` - CEFRLevel *string `json:"cefr_level"` - IsActive *bool `json:"is_active"` -} - -type humanLanguageLessonRes struct { - ID int64 `json:"id"` - CourseID int64 `json:"course_id"` - Title string `json:"title"` - Description *string `json:"description,omitempty"` - Thumbnail *string `json:"thumbnail,omitempty"` - DisplayOrder int32 `json:"display_order"` - Level string `json:"level"` - VideoCount int64 `json:"video_count"` - PracticeCount int64 `json:"practice_count"` - Videos []domain.LearningPathVideo `json:"videos"` - Practices []domain.LearningPathPractice `json:"practices"` -} - -type getHumanLanguageLessonsRes struct { - CourseID int64 `json:"course_id"` - CourseTitle string `json:"course_title"` - CEFRLevel string `json:"cefr_level"` - Lessons []humanLanguageLessonRes `json:"lessons"` -} - -type humanLanguageSubModuleRes struct { - ID int64 `json:"id"` - Title string `json:"title"` - Videos []domain.LearningPathVideo `json:"videos"` - Practices []domain.LearningPathPractice `json:"practices"` -} - -type humanLanguageModuleRes struct { - ID int64 `json:"id"` - Title string `json:"title"` - SubModules []humanLanguageSubModuleRes `json:"sub_modules"` -} - -type humanLanguageLevelRes struct { - Level string `json:"level"` - Modules []humanLanguageModuleRes `json:"modules"` -} - -type humanLanguageCourseTreeRes struct { - CourseID int64 `json:"course_id"` - CourseName string `json:"course_name"` - Levels []humanLanguageLevelRes `json:"levels"` -} - -type humanLanguageSubCategoryTreeRes struct { - SubCategoryID int64 `json:"sub_category_id"` - SubCategoryName string `json:"sub_category_name"` - Courses []humanLanguageCourseTreeRes `json:"courses"` -} - -type humanLanguageHierarchyRes struct { - CategoryID int64 `json:"category_id"` - CategoryName string `json:"category_name"` - SubCategories []humanLanguageSubCategoryTreeRes `json:"sub_categories"` -} - -// CreateHumanLanguageLesson godoc -// @Summary Create human-language lesson unit -// @Description Creates a lesson unit under a human-language course using CEFR level (A1..C3) -// @Tags human-language -// @Accept json -// @Produce json -// @Param body body createHumanLanguageLessonReq true "Create human-language lesson payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/human-language/lessons [post] -func (h *Handler) CreateHumanLanguageLesson(c *fiber.Ctx) error { - var req createHumanLanguageLessonReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) - } - req.CEFRLevel = strings.ToUpper(strings.TrimSpace(req.CEFRLevel)) - if !isValidHumanLanguageCEFRLevel(req.CEFRLevel) { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid CEFR level", - Error: "Use one of: A1,A2,A3,B1,B2,B3,C1,C2,C3", - }) - } - if _, err := h.ensureCourseIsHumanLanguage(c.Context(), req.CourseID); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid human-language course", Error: err.Error()}) - } - - created, err := h.courseMgmtSvc.CreateSubCourse( - c.Context(), - req.CourseID, - req.Title, - req.Description, - req.Thumbnail, - req.DisplayOrder, - coarseLevelFromCEFR(req.CEFRLevel), - req.CEFRLevel, - ) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create human-language lesson", Error: err.Error()}) - } - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Human-language lesson created successfully", - Data: subCourseRes{ - ID: created.ID, - CourseID: created.CourseID, - Title: created.Title, - Description: created.Description, - Thumbnail: created.Thumbnail, - DisplayOrder: created.DisplayOrder, - Level: created.SubLevel, - SubLevel: "", - IsActive: created.IsActive, - }, - }) -} - -// UpdateHumanLanguageLesson godoc -// @Summary Update human-language lesson unit -// @Description Updates a human-language lesson unit and CEFR level -// @Tags human-language -// @Accept json -// @Produce json -// @Param id path int true "Lesson (sub-course) ID" -// @Param body body updateHumanLanguageLessonReq true "Update human-language lesson payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/human-language/lessons/{id} [patch] -func (h *Handler) UpdateHumanLanguageLesson(c *fiber.Ctx) error { - id, err := strconv.ParseInt(c.Params("id"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid lesson ID", Error: err.Error()}) - } - var req updateHumanLanguageLessonReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) - } - - var levelPtr *string - var subLevelPtr *string - if req.CEFRLevel != nil { - normalized := strings.ToUpper(strings.TrimSpace(*req.CEFRLevel)) - if !isValidHumanLanguageCEFRLevel(normalized) { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid CEFR level", - Error: "Use one of: A1,A2,A3,B1,B2,B3,C1,C2,C3", - }) - } - level := coarseLevelFromCEFR(normalized) - levelPtr = &level - subLevelPtr = &normalized - } - - if err := h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, levelPtr, subLevelPtr, req.IsActive); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update human-language lesson", Error: err.Error()}) - } - return c.JSON(domain.Response{Message: "Human-language lesson updated successfully"}) -} - -// GetHumanLanguageLessonsByCourse godoc -// @Summary Get human-language lessons by CEFR level -// @Description Returns lessons for a human-language course filtered by CEFR level (A1..C3) -// @Tags human-language -// @Produce json -// @Param courseId path int true "Course ID" -// @Param cefr_level query string true "CEFR level (A1..C3)" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/human-language/courses/{courseId}/lessons [get] -func (h *Handler) GetHumanLanguageLessonsByCourse(c *fiber.Ctx) error { - courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: err.Error()}) - } - cefrLevel := strings.ToUpper(strings.TrimSpace(c.Query("cefr_level"))) - if !isValidHumanLanguageCEFRLevel(cefrLevel) { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid CEFR level", - Error: "Use one of: A1,A2,A3,B1,B2,B3,C1,C2,C3", - }) - } - if _, err := h.ensureCourseIsHumanLanguage(c.Context(), courseID); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid human-language course", Error: err.Error()}) - } - - path, err := h.courseMgmtSvc.GetCourseLearningPath(c.Context(), courseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to retrieve learning path", Error: err.Error()}) - } - - lessons := make([]humanLanguageLessonRes, 0) - for _, sc := range path.SubCourses { - if strings.ToUpper(strings.TrimSpace(sc.SubLevel)) != cefrLevel { - continue - } - lessons = append(lessons, humanLanguageLessonRes{ - ID: sc.ID, - CourseID: courseID, - Title: sc.Title, - Description: sc.Description, - Thumbnail: sc.Thumbnail, - DisplayOrder: sc.DisplayOrder, - Level: sc.SubLevel, - VideoCount: int64(len(sc.Videos)), - PracticeCount: int64(len(sc.Practices)), - Videos: sc.Videos, - Practices: sc.Practices, - }) - } - - return c.JSON(domain.Response{ - Message: "Human-language lessons retrieved successfully", - Data: getHumanLanguageLessonsRes{ - CourseID: path.CourseID, - CourseTitle: path.CourseTitle, - CEFRLevel: cefrLevel, - Lessons: lessons, - }, - }) -} - -// GetHumanLanguageHierarchy godoc -// @Summary Get full human-language hierarchy -// @Description Returns Category -> SubCategory -> Course -> Level -> Module -> SubModule with videos/practices -// @Tags human-language -// @Produce json -// @Success 200 {object} domain.Response -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/human-language/hierarchy [get] -func (h *Handler) GetHumanLanguageHierarchy(c *fiber.Ctx) error { - categories, _, err := h.courseMgmtSvc.GetAllCourseCategories(c.Context(), 1000, 0) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to load course categories", - Error: err.Error(), - }) - } - - var humanCategory *domain.CourseCategory - for _, cat := range categories { - name := strings.ToLower(strings.TrimSpace(cat.Name)) - if strings.Contains(name, "human language") || strings.Contains(name, "language") { - cc := cat - humanCategory = &cc - break - } - } - - if humanCategory == nil { - return c.JSON(domain.Response{ - Message: "Human-language hierarchy retrieved successfully", - Data: humanLanguageHierarchyRes{ - CategoryID: 0, - CategoryName: "Human Language", - SubCategories: []humanLanguageSubCategoryTreeRes{}, - }, - }) - } - - courses, _, err := h.courseMgmtSvc.GetCoursesByCategory(c.Context(), humanCategory.ID, 1000, 0) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to load human-language subcategories", - Error: err.Error(), - }) - } - - subCategories := make([]humanLanguageSubCategoryTreeRes, 0, len(courses)) - for _, course := range courses { - path, pathErr := h.courseMgmtSvc.GetCourseLearningPath(c.Context(), course.ID) - if pathErr != nil { - continue - } - - levelsMap := map[string][]humanLanguageModuleRes{} - for _, sc := range path.SubCourses { - levelKey := strings.ToUpper(strings.TrimSpace(sc.SubLevel)) - if !isValidHumanLanguageCEFRLevel(levelKey) { - continue - } - levelsMap[levelKey] = append(levelsMap[levelKey], humanLanguageModuleRes{ - ID: sc.ID, - Title: sc.Title, - SubModules: []humanLanguageSubModuleRes{ - { - ID: sc.ID, - Title: sc.Title, - Videos: sc.Videos, - Practices: sc.Practices, - }, - }, - }) - } - - levels := make([]humanLanguageLevelRes, 0, 9) - for _, cefr := range []string{ - string(domain.SubCourseSubLevelA1), string(domain.SubCourseSubLevelA2), string(domain.SubCourseSubLevelA3), - string(domain.SubCourseSubLevelB1), string(domain.SubCourseSubLevelB2), string(domain.SubCourseSubLevelB3), - string(domain.SubCourseSubLevelC1), string(domain.SubCourseSubLevelC2), string(domain.SubCourseSubLevelC3), - } { - raw := levelsMap[cefr] - moduleBuckets := map[int]*humanLanguageModuleRes{} - fallbackCounter := 1000000 - - for _, item := range raw { - moduleNo := fallbackCounter - subNo := 0 - matched := humanLanguageModulePattern.FindStringSubmatch(strings.TrimSpace(item.Title)) - if len(matched) > 0 { - if parsed, parseErr := strconv.Atoi(matched[1]); parseErr == nil { - moduleNo = parsed - } - if len(matched) > 2 && strings.TrimSpace(matched[2]) != "" { - if parsed, parseErr := strconv.Atoi(matched[2]); parseErr == nil { - subNo = parsed - } - } - } else { - fallbackCounter++ - } - - mod, exists := moduleBuckets[moduleNo] - if !exists { - moduleTitle := item.Title - if moduleNo < 1000000 { - moduleTitle = fmt.Sprintf("Module-%d", moduleNo) - } - mod = &humanLanguageModuleRes{ - ID: item.ID, - Title: moduleTitle, - SubModules: []humanLanguageSubModuleRes{}, - } - moduleBuckets[moduleNo] = mod - } - - subModuleTitle := item.Title - if moduleNo < 1000000 && subNo > 0 { - subModuleTitle = fmt.Sprintf("Sub-Module-%d.%d", moduleNo, subNo) - } else if moduleNo < 1000000 && subNo == 0 { - subModuleTitle = fmt.Sprintf("Sub-Module-%d.1", moduleNo) - } - - sub := humanLanguageSubModuleRes{ - ID: item.ID, - Title: subModuleTitle, - Videos: item.SubModules[0].Videos, - Practices: item.SubModules[0].Practices, - } - mod.SubModules = append(mod.SubModules, sub) - } - - moduleKeys := make([]int, 0, len(moduleBuckets)) - for key := range moduleBuckets { - moduleKeys = append(moduleKeys, key) - } - sort.Ints(moduleKeys) - - groupedModules := make([]humanLanguageModuleRes, 0, len(moduleKeys)) - for _, key := range moduleKeys { - mod := moduleBuckets[key] - sort.SliceStable(mod.SubModules, func(i, j int) bool { - ai := humanLanguageModulePattern.FindStringSubmatch(strings.ReplaceAll(mod.SubModules[i].Title, "Sub-", "")) - aj := humanLanguageModulePattern.FindStringSubmatch(strings.ReplaceAll(mod.SubModules[j].Title, "Sub-", "")) - ival := 0 - jval := 0 - if len(ai) > 2 { - ival, _ = strconv.Atoi(ai[2]) - } - if len(aj) > 2 { - jval, _ = strconv.Atoi(aj[2]) - } - return ival < jval - }) - groupedModules = append(groupedModules, *mod) - } - - levels = append(levels, humanLanguageLevelRes{ - Level: cefr, - Modules: groupedModules, - }) - } - - subCategories = append(subCategories, humanLanguageSubCategoryTreeRes{ - SubCategoryID: course.ID, - SubCategoryName: course.Title, - Courses: []humanLanguageCourseTreeRes{ - { - CourseID: course.ID, - CourseName: course.Title, - Levels: levels, - }, - }, - }) - } - - return c.JSON(domain.Response{ - Message: "Human-language hierarchy retrieved successfully", - Data: humanLanguageHierarchyRes{ - CategoryID: humanCategory.ID, - CategoryName: humanCategory.Name, - SubCategories: subCategories, - }, - }) -} - -// CreateSubCourse godoc -// @Summary Create a new sub-course -// @Description Creates a new sub-course under a specific course -// @Tags sub-courses -// @Accept json -// @Produce json -// @Param body body createSubCourseReq true "Create sub-course payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-courses [post] -func (h *Handler) CreateSubCourse(c *fiber.Ctx) error { - var req createSubCourseReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - 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", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - 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, "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() { - students, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: string(domain.RoleStudent)}) - if err != nil { - return - } - for _, s := range students { - h.sendInAppNotification(s.ID, domain.NOTIFICATION_TYPE_SUB_COURSE_CREATED, "New Content Available", "A new sub-course \""+subCourse.Title+"\" has been added.") - } - }() - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Sub-course created successfully", - Data: subCourseRes{ - ID: subCourse.ID, - CourseID: subCourse.CourseID, - Title: subCourse.Title, - Description: subCourse.Description, - Thumbnail: subCourse.Thumbnail, - DisplayOrder: subCourse.DisplayOrder, - Level: subCourse.Level, - SubLevel: subCourse.SubLevel, - IsActive: subCourse.IsActive, - }, - }) -} - -// GetSubCourseByID godoc -// @Summary Get sub-course by ID -// @Description Returns a single sub-course by its ID -// @Tags sub-courses -// @Produce json -// @Param id 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/course-management/sub-courses/{id} [get] -func (h *Handler) GetSubCourseByID(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - subCourse, err := h.courseMgmtSvc.GetSubCourseByID(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Sub-course not found", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Sub-course retrieved successfully", - Data: subCourseRes{ - ID: subCourse.ID, - CourseID: subCourse.CourseID, - Title: subCourse.Title, - Description: subCourse.Description, - Thumbnail: subCourse.Thumbnail, - DisplayOrder: subCourse.DisplayOrder, - Level: subCourse.Level, - SubLevel: subCourse.SubLevel, - IsActive: subCourse.IsActive, - }, - }) -} - -type getSubCoursesByCourseRes struct { - SubCourses []subCourseRes `json:"sub_courses"` - TotalCount int64 `json:"total_count"` -} - -// GetSubCoursesByCourse godoc -// @Summary Get sub-courses by course -// @Description Returns all sub-courses under a specific course -// @Tags sub-courses -// @Produce json -// @Param courseId path int true "Course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/courses/{courseId}/sub-courses [get] -func (h *Handler) GetSubCoursesByCourse(c *fiber.Ctx) error { - courseIDStr := c.Params("courseId") - courseID, err := strconv.ParseInt(courseIDStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid course ID", - Error: err.Error(), - }) - } - - subCourses, totalCount, err := h.courseMgmtSvc.GetSubCoursesByCourse(c.Context(), courseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve sub-courses", - Error: err.Error(), - }) - } - - var subCourseResponses []subCourseRes - for _, sc := range subCourses { - subCourseResponses = append(subCourseResponses, subCourseRes{ - ID: sc.ID, - CourseID: sc.CourseID, - Title: sc.Title, - Description: sc.Description, - Thumbnail: sc.Thumbnail, - DisplayOrder: sc.DisplayOrder, - Level: sc.Level, - SubLevel: sc.SubLevel, - IsActive: sc.IsActive, - }) - } - - return c.JSON(domain.Response{ - Message: "Sub-courses retrieved successfully", - Data: getSubCoursesByCourseRes{ - SubCourses: subCourseResponses, - TotalCount: totalCount, - }, - }) -} - -// ListSubCoursesByCourse godoc -// @Summary List active sub-courses by course -// @Description Returns a list of active sub-courses under a specific course -// @Tags sub-courses -// @Produce json -// @Param courseId path int true "Course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/courses/{courseId}/sub-courses/list [get] -func (h *Handler) ListSubCoursesByCourse(c *fiber.Ctx) error { - courseIDStr := c.Params("courseId") - courseID, err := strconv.ParseInt(courseIDStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid course ID", - Error: err.Error(), - }) - } - - subCourses, err := h.courseMgmtSvc.ListSubCoursesByCourse(c.Context(), courseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve sub-courses", - Error: err.Error(), - }) - } - - var subCourseResponses []subCourseRes - for _, sc := range subCourses { - subCourseResponses = append(subCourseResponses, subCourseRes{ - ID: sc.ID, - CourseID: sc.CourseID, - Title: sc.Title, - Description: sc.Description, - Thumbnail: sc.Thumbnail, - DisplayOrder: sc.DisplayOrder, - Level: sc.Level, - SubLevel: sc.SubLevel, - IsActive: sc.IsActive, - }) - } - - return c.JSON(domain.Response{ - Message: "Sub-courses retrieved successfully", - Data: subCourseResponses, - }) -} - -// ListActiveSubCourses godoc -// @Summary List all active sub-courses -// @Description Returns a list of all active sub-courses -// @Tags sub-courses -// @Produce json -// @Success 200 {object} domain.Response -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-courses/active [get] -func (h *Handler) ListActiveSubCourses(c *fiber.Ctx) error { - subCourses, err := h.courseMgmtSvc.ListActiveSubCourses(c.Context()) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve active sub-courses", - Error: err.Error(), - }) - } - - var subCourseResponses []subCourseRes - for _, sc := range subCourses { - subCourseResponses = append(subCourseResponses, subCourseRes{ - ID: sc.ID, - CourseID: sc.CourseID, - Title: sc.Title, - Description: sc.Description, - Thumbnail: sc.Thumbnail, - DisplayOrder: sc.DisplayOrder, - Level: sc.Level, - SubLevel: sc.SubLevel, - IsActive: sc.IsActive, - }) - } - - return c.JSON(domain.Response{ - Message: "Active sub-courses retrieved successfully", - Data: subCourseResponses, - }) -} - -type updateSubCourseReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - DisplayOrder *int32 `json:"display_order"` - Level *string `json:"level"` - SubLevel *string `json:"sub_level"` - IsActive *bool `json:"is_active"` -} - -// UpdateSubCourse godoc -// @Summary Update sub-course -// @Description Updates a sub-course's fields -// @Tags sub-courses -// @Accept json -// @Produce json -// @Param id path int true "Sub-course ID" -// @Param body body updateSubCourseReq true "Update sub-course payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-courses/{id} [patch] -func (h *Handler) UpdateSubCourse(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - var req updateSubCourseReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - 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", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - 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, "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{ - Message: "Sub-course updated successfully", - }) -} - -// DeactivateSubCourse godoc -// @Summary Deactivate sub-course -// @Description Deactivates a sub-course by its ID -// @Tags sub-courses -// @Produce json -// @Param id path int true "Sub-course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-courses/{id}/deactivate [put] -func (h *Handler) DeactivateSubCourse(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.DeactivateSubCourse(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to deactivate sub-course", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"id": id}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseDeactivated, domain.ResourceSubCourse, &id, fmt.Sprintf("Deactivated sub-course ID: %d", id), meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Sub-course deactivated successfully", - }) -} - -// DeleteSubCourse godoc -// @Summary Delete sub-course -// @Description Deletes a sub-course by its ID -// @Tags sub-courses -// @Produce json -// @Param id path int true "Sub-course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-courses/{id} [delete] -func (h *Handler) DeleteSubCourse(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - _, err = h.courseMgmtSvc.DeleteSubCourse(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to delete sub-course", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"id": id}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseDeleted, domain.ResourceSubCourse, &id, fmt.Sprintf("Deleted sub-course ID: %d", id), meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Sub-course deleted successfully", - }) -} - -// Sub-course Video Handlers - -type createSubCourseVideoReq struct { - SubCourseID int64 `json:"sub_course_id" validate:"required"` - Title string `json:"title" validate:"required"` - Description *string `json:"description"` - VideoURL string `json:"video_url" validate:"required"` - Duration int32 `json:"duration" validate:"required"` - Resolution *string `json:"resolution"` - InstructorID *string `json:"instructor_id"` - Thumbnail *string `json:"thumbnail"` - Visibility *string `json:"visibility"` - DisplayOrder *int32 `json:"display_order"` - Status *string `json:"status"` // DRAFT, PUBLISHED, INACTIVE, ARCHIVED -} - -type subCourseVideoRes struct { - ID int64 `json:"id"` - SubCourseID int64 `json:"sub_course_id"` - Title string `json:"title"` - Description *string `json:"description"` - VideoURL string `json:"video_url"` - Duration int32 `json:"duration"` - Resolution *string `json:"resolution"` - InstructorID *string `json:"instructor_id"` - Thumbnail *string `json:"thumbnail"` - Visibility *string `json:"visibility"` - DisplayOrder int32 `json:"display_order"` - IsPublished bool `json:"is_published"` - PublishDate *string `json:"publish_date"` - Status string `json:"status"` - VimeoID *string `json:"vimeo_id,omitempty"` - VimeoEmbedURL *string `json:"vimeo_embed_url,omitempty"` - VimeoPlayerHTML *string `json:"vimeo_player_html,omitempty"` - VimeoStatus *string `json:"vimeo_status,omitempty"` -} - -type createVimeoVideoReq struct { - SubCourseID int64 `json:"sub_course_id" validate:"required"` - Title string `json:"title" validate:"required"` - Description *string `json:"description"` - SourceURL string `json:"source_url" validate:"required,url"` - FileSize int64 `json:"file_size" validate:"required,gt=0"` - Duration int32 `json:"duration"` - Resolution *string `json:"resolution"` - InstructorID *string `json:"instructor_id"` - Thumbnail *string `json:"thumbnail"` - Visibility *string `json:"visibility"` - DisplayOrder *int32 `json:"display_order"` -} - -type createVideoFromVimeoIDReq struct { - SubCourseID int64 `json:"sub_course_id" validate:"required"` - VimeoVideoID string `json:"vimeo_video_id" validate:"required"` - Title string `json:"title" validate:"required"` - Description *string `json:"description"` - DisplayOrder *int32 `json:"display_order"` - InstructorID *string `json:"instructor_id"` -} - -// CreateSubCourseVideo godoc -// @Summary Create a new sub-course video -// @Description Creates a new video under a specific sub-course -// @Tags sub-course-videos -// @Accept json -// @Produce json -// @Param body body createSubCourseVideoReq true "Create video payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/videos [post] -func (h *Handler) CreateSubCourseVideo(c *fiber.Ctx) error { - var req createSubCourseVideoReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - video, err := h.courseMgmtSvc.CreateSubCourseVideo(c.Context(), req.SubCourseID, req.Title, req.Description, req.VideoURL, req.Duration, req.Resolution, req.InstructorID, req.Thumbnail, req.Visibility, req.DisplayOrder, req.Status) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to create sub-course video", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"title": video.Title, "sub_course_id": video.SubCourseID}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoCreated, domain.ResourceVideo, &video.ID, "Created video: "+video.Title, meta, &ip, &ua) - - go func() { - students, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: string(domain.RoleStudent)}) - if err != nil { - return - } - for _, s := range students { - h.sendInAppNotification(s.ID, domain.NOTIFICATION_TYPE_VIDEO_ADDED, "New Video Available", "A new video \""+req.Title+"\" has been added.") - } - }() - - var publishDate *string - if video.PublishDate != nil { - pd := video.PublishDate.String() - publishDate = &pd - } - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Sub-course video created successfully", - Data: subCourseVideoRes{ - ID: video.ID, - SubCourseID: video.SubCourseID, - Title: video.Title, - Description: video.Description, - VideoURL: video.VideoURL, - Duration: video.Duration, - Resolution: video.Resolution, - InstructorID: video.InstructorID, - Thumbnail: video.Thumbnail, - Visibility: video.Visibility, - DisplayOrder: video.DisplayOrder, - IsPublished: video.IsPublished, - PublishDate: publishDate, - Status: video.Status, - }, - }) -} - -// GetSubCourseVideoByID godoc -// @Summary Get sub-course video by ID -// @Description Returns a single video by its ID -// @Tags sub-course-videos -// @Produce json -// @Param id path int true "Video ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 404 {object} domain.ErrorResponse -// @Router /api/v1/course-management/videos/{id} [get] -func (h *Handler) GetSubCourseVideoByID(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 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(), id) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Video not found", - Error: err.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() - publishDate = &pd - } - - return c.JSON(domain.Response{ - Message: "Video retrieved successfully", - Data: subCourseVideoRes{ - ID: video.ID, - SubCourseID: video.SubCourseID, - Title: video.Title, - Description: video.Description, - VideoURL: video.VideoURL, - Duration: video.Duration, - Resolution: video.Resolution, - InstructorID: video.InstructorID, - Thumbnail: video.Thumbnail, - Visibility: video.Visibility, - DisplayOrder: video.DisplayOrder, - IsPublished: video.IsPublished, - PublishDate: publishDate, - Status: video.Status, - }, - }) -} - -// 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(), - }) - } - if err := h.courseMgmtSvc.RecalculateSubCourseProgress(c.Context(), userID, video.SubCourseID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update sub-course progress", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Video completed", - }) -} - -type getVideosBySubCourseRes struct { - Videos []subCourseVideoRes `json:"videos"` - TotalCount int64 `json:"total_count"` -} - -// GetVideosBySubCourse godoc -// @Summary Get videos by sub-course -// @Description Returns all videos under a specific sub-course -// @Tags sub-course-videos -// @Produce json -// @Param subCourseId path int true "Sub-course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-courses/{subCourseId}/videos [get] -func (h *Handler) GetVideosBySubCourse(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(), - }) - } - - videos, totalCount, err := h.courseMgmtSvc.GetVideosBySubCourse(c.Context(), subCourseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve videos", - Error: err.Error(), - }) - } - - videoResponses := make([]subCourseVideoRes, 0, len(videos)) - for _, v := range videos { - videoResponses = append(videoResponses, mapVideoToResponse(v)) - } - - return c.JSON(domain.Response{ - Message: "Videos retrieved successfully", - Data: getVideosBySubCourseRes{ - Videos: videoResponses, - TotalCount: totalCount, - }, - }) -} - -// GetPublishedVideosBySubCourse godoc -// @Summary Get published videos by sub-course -// @Description Returns all published videos under a specific sub-course -// @Tags sub-course-videos -// @Produce json -// @Param subCourseId path int true "Sub-course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-courses/{subCourseId}/videos/published [get] -func (h *Handler) GetPublishedVideosBySubCourse(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(), - }) - } - - videos, err := h.courseMgmtSvc.GetPublishedVideosBySubCourse(c.Context(), subCourseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve published videos", - Error: err.Error(), - }) - } - - videoResponses := make([]subCourseVideoRes, 0, len(videos)) - for _, v := range videos { - videoResponses = append(videoResponses, mapVideoToResponse(v)) - } - - return c.JSON(domain.Response{ - Message: "Published videos retrieved successfully", - Data: videoResponses, - }) -} - -// PublishSubCourseVideo godoc -// @Summary Publish sub-course video -// @Description Publishes a video by its ID -// @Tags sub-course-videos -// @Produce json -// @Param id path int true "Video ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/videos/{id}/publish [put] -func (h *Handler) PublishSubCourseVideo(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid video ID", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.PublishSubCourseVideo(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to publish video", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"id": id}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoPublished, domain.ResourceVideo, &id, fmt.Sprintf("Published video ID: %d", id), meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Video published successfully", - }) -} - -type updateSubCourseVideoReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - VideoURL *string `json:"video_url"` - Duration *int32 `json:"duration"` - Resolution *string `json:"resolution"` - Visibility *string `json:"visibility"` - Thumbnail *string `json:"thumbnail"` - DisplayOrder *int32 `json:"display_order"` - Status *string `json:"status"` // DRAFT, PUBLISHED, INACTIVE, ARCHIVED -} - -// UpdateSubCourseVideo godoc -// @Summary Update sub-course video -// @Description Updates a video's fields -// @Tags sub-course-videos -// @Accept json -// @Produce json -// @Param id path int true "Video ID" -// @Param body body updateSubCourseVideoReq true "Update video payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/videos/{id} [put] -func (h *Handler) UpdateSubCourseVideo(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid video ID", - Error: err.Error(), - }) - } - - var req updateSubCourseVideoReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.UpdateSubCourseVideo(c.Context(), id, req.Title, req.Description, req.VideoURL, req.Duration, req.Resolution, req.Visibility, req.Thumbnail, req.DisplayOrder, req.Status) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update video", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - 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}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoUpdated, domain.ResourceVideo, &id, fmt.Sprintf("Updated video ID: %d", id), meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Video updated successfully", - }) -} - -// DeleteSubCourseVideo godoc -// @Summary Delete sub-course video -// @Description Archives a video by its ID (soft delete) -// @Tags sub-course-videos -// @Produce json -// @Param id path int true "Video ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/videos/{id} [delete] -func (h *Handler) DeleteSubCourseVideo(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid video ID", - Error: err.Error(), - }) - } - - err = h.courseMgmtSvc.ArchiveSubCourseVideo(c.Context(), id) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to delete video", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"id": id}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoArchived, domain.ResourceVideo, &id, fmt.Sprintf("Archived video ID: %d", id), meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Video deleted successfully", - }) -} - -// NOTE: Practice and Practice Question handlers have been removed. -// Use the unified questions system at /api/v1/questions and /api/v1/question-sets instead. -// Create a question set with set_type="PRACTICE" and owner_type="SUB_COURSE" to replace practices. - -// Learning Tree Handler - -// GetFullLearningTree godoc -// @Summary Get full learning tree -// @Description Returns the complete learning tree structure with courses and sub-courses -// @Tags learning-tree -// @Produce json -// @Success 200 {object} domain.Response -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/learning-tree [get] -func (h *Handler) GetFullLearningTree(c *fiber.Ctx) error { - courses, err := h.courseMgmtSvc.GetFullLearningTree(c.Context()) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to retrieve learning tree", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Learning tree retrieved successfully", - Data: courses, - }) -} - -// GetCourseLearningPath godoc -// @Summary Get course learning path -// @Description Returns the complete learning path for a course including sub-courses (by level), -// @Description video lessons, practices, prerequisites, and counts — structured for admin panel flexible path configuration -// @Tags learning-tree -// @Produce json -// @Param courseId path int true "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/course-management/courses/{courseId}/learning-path [get] -func (h *Handler) GetCourseLearningPath(c *fiber.Ctx) error { - courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid course ID", - Error: err.Error(), - }) - } - - path, err := h.courseMgmtSvc.GetCourseLearningPath(c.Context(), courseID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Course not found or has no learning path", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Learning path retrieved successfully", - Data: path, - }) -} - -// Reorder Handlers — support drag-and-drop ordering from admin panel - -type reorderItem struct { - ID int64 `json:"id" validate:"required"` - Position int32 `json:"position"` -} - -type reorderReq struct { - Items []reorderItem `json:"items" validate:"required,min=1"` -} - -func parseReorderItems(items []reorderItem) ([]int64, []int32) { - ids := make([]int64, len(items)) - positions := make([]int32, len(items)) - for i, item := range items { - ids[i] = item.ID - positions[i] = item.Position - } - return ids, positions -} - -// ReorderCourseCategories godoc -// @Summary Reorder course categories -// @Description Updates the display_order of course categories for drag-and-drop sorting -// @Tags course-categories -// @Accept json -// @Produce json -// @Param body body reorderReq true "Reorder payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/categories/reorder [put] -func (h *Handler) ReorderCourseCategories(c *fiber.Ctx) error { - var req reorderReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - if len(req.Items) == 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Items array is required", - Error: "items must not be empty", - }) - } - - ids, positions := parseReorderItems(req.Items) - if err := h.courseMgmtSvc.ReorderCourseCategories(c.Context(), ids, positions); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to reorder course categories", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryUpdated, domain.ResourceCategory, nil, "Reordered course categories", meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Course categories reordered successfully", - }) -} - -// ReorderCourses godoc -// @Summary Reorder courses within a category -// @Description Updates the display_order of courses for drag-and-drop sorting -// @Tags courses -// @Accept json -// @Produce json -// @Param body body reorderReq true "Reorder payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/courses/reorder [put] -func (h *Handler) ReorderCourses(c *fiber.Ctx) error { - var req reorderReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - if len(req.Items) == 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Items array is required", - Error: "items must not be empty", - }) - } - - ids, positions := parseReorderItems(req.Items) - if err := h.courseMgmtSvc.ReorderCourses(c.Context(), ids, positions); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to reorder courses", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceCourse, nil, "Reordered courses", meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Courses reordered successfully", - }) -} - -// ReorderSubCourses godoc -// @Summary Reorder sub-courses within a course -// @Description Updates the display_order of sub-courses for drag-and-drop sorting -// @Tags sub-courses -// @Accept json -// @Produce json -// @Param body body reorderReq true "Reorder payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-courses/reorder [put] -func (h *Handler) ReorderSubCourses(c *fiber.Ctx) error { - var req reorderReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - if len(req.Items) == 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Items array is required", - Error: "items must not be empty", - }) - } - - ids, positions := parseReorderItems(req.Items) - if err := h.courseMgmtSvc.ReorderSubCourses(c.Context(), ids, positions); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to reorder sub-courses", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseUpdated, domain.ResourceSubCourse, nil, "Reordered sub-courses", meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Sub-courses reordered successfully", - }) -} - -// ReorderSubCourseVideos godoc -// @Summary Reorder videos within a sub-course -// @Description Updates the display_order of videos for drag-and-drop sorting -// @Tags sub-course-videos -// @Accept json -// @Produce json -// @Param body body reorderReq true "Reorder payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/videos/reorder [put] -func (h *Handler) ReorderSubCourseVideos(c *fiber.Ctx) error { - var req reorderReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - if len(req.Items) == 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Items array is required", - Error: "items must not be empty", - }) - } - - ids, positions := parseReorderItems(req.Items) - if err := h.courseMgmtSvc.ReorderSubCourseVideos(c.Context(), ids, positions); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to reorder videos", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoUpdated, domain.ResourceVideo, nil, "Reordered sub-course videos", meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Videos reordered successfully", - }) -} - -// ReorderPractices godoc -// @Summary Reorder practices (question sets) within a sub-course -// @Description Updates the display_order of practices for drag-and-drop sorting -// @Tags question-sets -// @Accept json -// @Produce json -// @Param body body reorderReq true "Reorder payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/practices/reorder [put] -func (h *Handler) ReorderPractices(c *fiber.Ctx) error { - var req reorderReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - if len(req.Items) == 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Items array is required", - Error: "items must not be empty", - }) - } - - ids, positions := parseReorderItems(req.Items) - if err := h.courseMgmtSvc.ReorderQuestionSets(c.Context(), ids, positions); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to reorder practices", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, nil, "Reordered practices", meta, &ip, &ua) - - return c.JSON(domain.Response{ - Message: "Practices reordered successfully", - }) -} - -// UploadSubCourseVideo godoc -// @Summary Upload a video file and create sub-course video -// @Description Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record -// @Tags sub-course-videos -// @Accept multipart/form-data -// @Produce json -// @Param file formData file true "Video file" -// @Param sub_course_id formData int true "Sub-course ID" -// @Param title formData string true "Video title" -// @Param description formData string false "Video description" -// @Param duration formData int false "Duration in seconds" -// @Param resolution formData string false "Video resolution" -// @Param instructor_id formData string false "Instructor ID" -// @Param thumbnail formData string false "Thumbnail URL" -// @Param visibility formData string false "Visibility" -// @Param display_order formData int false "Display order" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/videos/upload [post] -func (h *Handler) UploadSubCourseVideo(c *fiber.Ctx) error { - subCourseIDStr := c.FormValue("sub_course_id") - if subCourseIDStr == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "sub_course_id is required", - Error: "sub_course_id form field is empty", - }) - } - 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(), - }) - } - - title := c.FormValue("title") - if title == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "title is required", - Error: "title form field is empty", - }) - } - - fileHeader, err := c.FormFile("file") - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Video file is required", - Error: err.Error(), - }) - } - - const maxSize = 500 * 1024 * 1024 // 500 MB - if fileHeader.Size > maxSize { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "File too large", - Error: "Video file must be <= 500MB", - }) - } - - file, err := fileHeader.Open() - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to read uploaded file", - Error: err.Error(), - }) - } - defer file.Close() - - var description *string - if desc := c.FormValue("description"); desc != "" { - description = &desc - } - - var duration int32 - if durStr := c.FormValue("duration"); durStr != "" { - durVal, err := strconv.ParseInt(durStr, 10, 32) - if err == nil { - duration = int32(durVal) - } - } - - var resolution *string - if res := c.FormValue("resolution"); res != "" { - resolution = &res - } - - var instructorID *string - if iid := c.FormValue("instructor_id"); iid != "" { - instructorID = &iid - } - - var thumbnail *string - if thumb := c.FormValue("thumbnail"); thumb != "" { - thumbnail = &thumb - } - - var visibility *string - if vis := c.FormValue("visibility"); vis != "" { - visibility = &vis - } - - var displayOrder *int32 - if doStr := c.FormValue("display_order"); doStr != "" { - doVal, err := strconv.ParseInt(doStr, 10, 32) - if err == nil { - do := int32(doVal) - displayOrder = &do - } - } - - video, err := h.courseMgmtSvc.CreateSubCourseVideoWithFileUpload( - c.Context(), subCourseID, title, description, - fileHeader.Filename, file, fileHeader.Size, duration, resolution, - instructorID, thumbnail, visibility, displayOrder, - ) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to upload video", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"title": video.Title, "sub_course_id": subCourseID}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoUploaded, domain.ResourceVideo, &video.ID, "Uploaded video: "+video.Title, meta, &ip, &ua) - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Video uploaded and created successfully", - Data: mapVideoToResponse(video), - Success: true, - }) -} - -// CreateSubCourseVideoWithVimeo godoc -// @Summary Create a new sub-course video with Vimeo upload -// @Description Creates a video by uploading to Vimeo from a source URL -// @Tags sub-course-videos -// @Accept json -// @Produce json -// @Param body body createVimeoVideoReq true "Create Vimeo video payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/videos/vimeo [post] -func (h *Handler) CreateSubCourseVideoWithVimeo(c *fiber.Ctx) error { - var req createVimeoVideoReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - if valErrs, ok := h.validator.Validate(c, req); !ok { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Validation failed", - Error: fmt.Sprintf("%v", valErrs), - }) - } - - video, err := h.courseMgmtSvc.CreateSubCourseVideoWithVimeo( - c.Context(), req.SubCourseID, req.Title, req.Description, - req.SourceURL, req.FileSize, req.Duration, req.Resolution, - req.InstructorID, req.Thumbnail, req.Visibility, req.DisplayOrder, - ) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to create video with Vimeo", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"title": video.Title, "sub_course_id": video.SubCourseID}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoCreated, domain.ResourceVideo, &video.ID, "Created video with Vimeo: "+video.Title, meta, &ip, &ua) - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Video created and uploaded to Vimeo successfully", - Data: mapVideoToResponse(video), - Success: true, - }) -} - -// CreateSubCourseVideoFromVimeoID godoc -// @Summary Create a sub-course video from existing Vimeo video -// @Description Creates a video record from an existing Vimeo video ID -// @Tags sub-course-videos -// @Accept json -// @Produce json -// @Param body body createVideoFromVimeoIDReq true "Create from Vimeo ID payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/videos/vimeo/import [post] -func (h *Handler) CreateSubCourseVideoFromVimeoID(c *fiber.Ctx) error { - var req createVideoFromVimeoIDReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - if valErrs, ok := h.validator.Validate(c, req); !ok { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Validation failed", - Error: fmt.Sprintf("%v", valErrs), - }) - } - - video, err := h.courseMgmtSvc.CreateSubCourseVideoFromVimeoID( - c.Context(), req.SubCourseID, req.VimeoVideoID, req.Title, - req.Description, req.DisplayOrder, req.InstructorID, - ) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to import video from Vimeo", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"title": video.Title, "sub_course_id": video.SubCourseID, "vimeo_video_id": req.VimeoVideoID}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoCreated, domain.ResourceVideo, &video.ID, "Imported video from Vimeo: "+video.Title, meta, &ip, &ua) - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Video imported from Vimeo successfully", - Data: mapVideoToResponse(video), - Success: true, - }) -} - -// UploadCourseThumbnail godoc -// @Summary Upload a thumbnail image for a course -// @Description Uploads and optimizes a thumbnail image, then updates the course -// @Tags courses -// @Accept multipart/form-data -// @Produce json -// @Param id path int true "Course ID" -// @Param file formData file true "Thumbnail image file (jpg, png, webp)" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/courses/{id}/thumbnail [post] -func (h *Handler) UploadCourseThumbnail(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid course ID", - Error: err.Error(), - }) - } - - if strings.Contains(strings.ToLower(c.Get("Content-Type")), "application/json") { - var req struct { - ThumbnailURL string `json:"thumbnail_url"` - } - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - if strings.TrimSpace(req.ThumbnailURL) == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "thumbnail_url is required", - }) - } - if err := h.courseMgmtSvc.UpdateCourse(c.Context(), id, nil, nil, &req.ThumbnailURL, nil, nil); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update course thumbnail", - Error: err.Error(), - }) - } - return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Course thumbnail URL updated successfully", - Data: map[string]string{"thumbnail_url": req.ThumbnailURL}, - Success: true, - }) - } - - publicPath, err := h.processAndSaveThumbnail(c, "thumbnails/courses") - if err != nil { - return err - } - - if err := h.courseMgmtSvc.UpdateCourse(c.Context(), id, nil, nil, &publicPath, nil, nil); err != nil { - _ = os.Remove(filepath.Join(".", publicPath)) - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update course thumbnail", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"course_id": id, "thumbnail": publicPath}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceCourse, &id, fmt.Sprintf("Uploaded thumbnail for course ID: %d", id), meta, &ip, &ua) - - return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Course thumbnail uploaded successfully", - Data: map[string]string{"thumbnail_url": h.resolveFileURL(c, publicPath)}, - Success: true, - }) -} - -// UploadSubCourseThumbnail godoc -// @Summary Upload a thumbnail image for a sub-course -// @Description Uploads and optimizes a thumbnail image, then updates the sub-course -// @Tags sub-courses -// @Accept multipart/form-data -// @Produce json -// @Param id path int true "Sub-course ID" -// @Param file formData file true "Thumbnail image file (jpg, png, webp)" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-courses/{id}/thumbnail [post] -func (h *Handler) UploadSubCourseThumbnail(c *fiber.Ctx) error { - idStr := c.Params("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - if strings.Contains(strings.ToLower(c.Get("Content-Type")), "application/json") { - var req struct { - ThumbnailURL string `json:"thumbnail_url"` - } - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - if strings.TrimSpace(req.ThumbnailURL) == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "thumbnail_url is required", - }) - } - if err := h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, nil, nil, &req.ThumbnailURL, nil, nil, nil, nil); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update sub-course thumbnail", - Error: err.Error(), - }) - } - return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Sub-course thumbnail URL updated successfully", - Data: map[string]string{"thumbnail_url": req.ThumbnailURL}, - Success: true, - }) - } - - publicPath, err := h.processAndSaveThumbnail(c, "thumbnails/sub_courses") - if err != nil { - return err - } - - 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", - Error: err.Error(), - }) - } - - actorID := c.Locals("user_id").(int64) - actorRole := string(c.Locals("role").(domain.Role)) - ip := c.IP() - ua := c.Get("User-Agent") - meta, _ := json.Marshal(map[string]interface{}{"sub_course_id": id, "thumbnail": publicPath}) - go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseUpdated, domain.ResourceSubCourse, &id, fmt.Sprintf("Uploaded thumbnail for sub-course ID: %d", id), meta, &ip, &ua) - - return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Sub-course thumbnail uploaded successfully", - Data: map[string]string{"thumbnail_url": h.resolveFileURL(c, publicPath)}, - Success: true, - }) -} - -// processAndSaveThumbnail handles file validation, CloudConvert optimization, and local storage. -// It returns the public URL path or a fiber error response. -func (h *Handler) processAndSaveThumbnail(c *fiber.Ctx, subDir string) (string, error) { - fileHeader, err := c.FormFile("file") - if err != nil { - return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Image file is required", - Error: err.Error(), - }) - } - - const maxSize = 10 * 1024 * 1024 // 10 MB - if fileHeader.Size > maxSize { - return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "File too large", - Error: "Thumbnail image must be <= 10MB", - }) - } - - fh, err := fileHeader.Open() - if err != nil { - return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to read file", - Error: err.Error(), - }) - } - defer fh.Close() - - head := make([]byte, 512) - n, _ := fh.Read(head) - contentType := http.DetectContentType(head[:n]) - if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" { - return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid file type", - Error: "Only jpg, png and webp images are allowed", - }) - } - - rest, err := io.ReadAll(fh) - if err != nil { - return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to read file", - Error: err.Error(), - }) - } - data := append(head[:n], rest...) - - // Optimize via CloudConvert if available - if h.cloudConvertSvc != nil { - optimized, optErr := h.cloudConvertSvc.OptimizeImage( - c.Context(), fileHeader.Filename, - bytes.NewReader(data), int64(len(data)), - 1200, 80, - ) - if optErr != nil { - h.mongoLoggerSvc.Warn("CloudConvert thumbnail optimization failed, using original", - zap.Error(optErr), - ) - } else { - optimizedData, readErr := io.ReadAll(optimized.Data) - optimized.Data.Close() - if readErr == nil { - data = optimizedData - contentType = "image/webp" - } - } - } - - ext := ".jpg" - switch contentType { - case "image/png": - ext = ".png" - case "image/webp": - ext = ".webp" - } - - // Upload to MinIO if available, otherwise save locally - if h.minioSvc != nil { - result, uploadErr := h.minioSvc.Upload(c.Context(), subDir, fileHeader.Filename, bytes.NewReader(data), int64(len(data)), contentType) - if uploadErr != nil { - return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to upload file to storage", - Error: uploadErr.Error(), - }) - } - return "minio://" + result.ObjectKey, nil - } - - dir := filepath.Join(".", "static", subDir) - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to create storage directory", - Error: err.Error(), - }) - } - - filename := uuid.New().String() + ext - fullpath := filepath.Join(dir, filename) - - if err := os.WriteFile(fullpath, data, 0o644); err != nil { - return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to save file", - Error: err.Error(), - }) - } - - return "/static/" + subDir + "/" + filename, nil -} - -// Helper function to map video to response -func mapVideoToResponse(video domain.SubCourseVideo) subCourseVideoRes { - var publishDate *string - if video.PublishDate != nil { - pd := video.PublishDate.Format("2006-01-02T15:04:05Z07:00") - publishDate = &pd - } - - return subCourseVideoRes{ - ID: video.ID, - SubCourseID: video.SubCourseID, - Title: video.Title, - Description: video.Description, - VideoURL: video.VideoURL, - Duration: video.Duration, - Resolution: video.Resolution, - InstructorID: video.InstructorID, - Thumbnail: video.Thumbnail, - Visibility: video.Visibility, - DisplayOrder: video.DisplayOrder, - IsPublished: video.IsPublished, - PublishDate: publishDate, - Status: video.Status, - VimeoID: video.VimeoID, - VimeoEmbedURL: video.VimeoEmbedURL, - VimeoPlayerHTML: video.VimeoPlayerHTML, - VimeoStatus: video.VimeoStatus, - } -} diff --git a/internal/web_server/handlers/file_handler.go b/internal/web_server/handlers/file_handler.go index e6c5a6d..ca6e419 100644 --- a/internal/web_server/handlers/file_handler.go +++ b/internal/web_server/handlers/file_handler.go @@ -313,7 +313,7 @@ func normalizeAndValidateMediaContentType(mediaType, contentType, fileName strin // @Summary Upload an audio file // @Tags files // @Accept multipart/form-data -// @Param file formance file true "Audio file (mp3, wav, ogg, m4a, aac, webm)" +// @Param file formData file true "Audio file (mp3, wav, ogg, m4a, aac, webm)" // @Success 200 {object} domain.Response // @Router /api/v1/files/audio [post] func (h *Handler) UploadAudio(c *fiber.Ctx) error { diff --git a/internal/web_server/handlers/hierarchy_handler.go b/internal/web_server/handlers/hierarchy_handler.go new file mode 100644 index 0000000..49ec5b9 --- /dev/null +++ b/internal/web_server/handlers/hierarchy_handler.go @@ -0,0 +1,382 @@ +package handlers + +import ( + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5/pgtype" +) + +type createCourseSubCategoryReq struct { + CategoryID int64 `json:"category_id"` + Name string `json:"name"` + Description *string `json:"description"` + DisplayOrder *int32 `json:"display_order"` + IsActive *bool `json:"is_active"` +} + +type createLevelReq struct { + CourseID int64 `json:"course_id"` + CEFRLevel string `json:"cefr_level"` + DisplayOrder *int32 `json:"display_order"` + IsActive *bool `json:"is_active"` +} + +type createModuleReq struct { + LevelID int64 `json:"level_id"` + Title string `json:"title"` + Description *string `json:"description"` + DisplayOrder *int32 `json:"display_order"` + IsActive *bool `json:"is_active"` +} + +type createSubModuleReq struct { + ModuleID int64 `json:"module_id"` + Title string `json:"title"` + Description *string `json:"description"` + DisplayOrder *int32 `json:"display_order"` + IsActive *bool `json:"is_active"` +} + +type createSubModuleVideoReq struct { + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description *string `json:"description"` + VideoURL string `json:"video_url"` + Duration *int32 `json:"duration"` + Resolution *string `json:"resolution"` + Visibility *string `json:"visibility"` + InstructorID *string `json:"instructor_id"` + Thumbnail *string `json:"thumbnail"` + DisplayOrder *int32 `json:"display_order"` + Status *string `json:"status"` +} + +type attachSubModuleLessonReq struct { + SubModuleID int64 `json:"sub_module_id"` + QuestionSetID int64 `json:"question_set_id"` + IntroVideoURL *string `json:"intro_video_url"` + DisplayOrder *int32 `json:"display_order"` + IsActive *bool `json:"is_active"` +} + +type createSubModulePracticeReq struct { + SubModuleID int64 `json:"sub_module_id"` + Title string `json:"title"` + Description *string `json:"description"` + Thumbnail *string `json:"thumbnail"` + IntroVideoURL *string `json:"intro_video_url"` + QuestionSetID int64 `json:"question_set_id"` + DisplayOrder *int32 `json:"display_order"` + IsActive *bool `json:"is_active"` +} + +func toText(v *string) pgtype.Text { + if v == nil { + return pgtype.Text{Valid: false} + } + return pgtype.Text{String: *v, Valid: true} +} + +func toInt4(v *int32) pgtype.Int4 { + if v == nil { + return pgtype.Int4{Valid: false} + } + return pgtype.Int4{Int32: *v, Valid: true} +} + +func boolOrNil(v *bool) interface{} { + if v == nil { + return nil + } + return *v +} + +func intOrNil(v *int32) interface{} { + if v == nil { + return nil + } + return *v +} + +// UnifiedHierarchy godoc +// @Summary Get unified course hierarchy +// @Description Returns full hierarchy: category -> sub-category -> course +// @Tags course-management +// @Produce json +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/hierarchy [get] +func (h *Handler) UnifiedHierarchy(c *fiber.Ctx) error { + rows, err := h.analyticsDB.GetCoursesWithHierarchy(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load hierarchy", Error: err.Error()}) + } + return c.JSON(domain.Response{Message: "Unified hierarchy retrieved successfully", Data: rows}) +} + +// UnifiedHierarchyByCourse godoc +// @Summary Get hierarchy for a course +// @Description Returns hierarchy nodes for one course including levels/modules/sub-modules +// @Tags course-management +// @Produce json +// @Param courseId path int true "Course ID" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/courses/{courseId}/hierarchy [get] +func (h *Handler) UnifiedHierarchyByCourse(c *fiber.Ctx) error { + courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: err.Error()}) + } + rows, err := h.analyticsDB.GetFullHierarchyByCourseID(c.Context(), courseID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course hierarchy", Error: err.Error()}) + } + return c.JSON(domain.Response{Message: "Course hierarchy retrieved successfully", Data: rows}) +} + +// CreateCourseSubCategory godoc +// @Summary Create course sub-category +// @Description Creates a sub-category under a course category +// @Tags course-management +// @Accept json +// @Produce json +// @Param body body createCourseSubCategoryReq true "Create sub-category payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-categories [post] +func (h *Handler) CreateCourseSubCategory(c *fiber.Ctx) error { + var req createCourseSubCategoryReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + if req.CategoryID <= 0 || strings.TrimSpace(req.Name) == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "category_id and name are required"}) + } + created, err := h.analyticsDB.CreateCourseSubCategory(c.Context(), dbgen.CreateCourseSubCategoryParams{ + CategoryID: req.CategoryID, + Name: req.Name, + Description: toText(req.Description), + Column4: intOrNil(req.DisplayOrder), + Column5: boolOrNil(req.IsActive), + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-category", Error: err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Course sub-category created", Data: created}) +} + +// CreateLevel godoc +// @Summary Create level +// @Description Creates a CEFR level under a course +// @Tags course-management +// @Accept json +// @Produce json +// @Param body body createLevelReq true "Create level payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/levels [post] +func (h *Handler) CreateLevel(c *fiber.Ctx) error { + var req createLevelReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + req.CEFRLevel = strings.ToUpper(strings.TrimSpace(req.CEFRLevel)) + validCEFR := map[string]bool{"A1": true, "A2": true, "A3": true, "B1": true, "B2": true, "B3": true, "C1": true, "C2": true, "C3": true} + if req.CourseID <= 0 || !validCEFR[req.CEFRLevel] { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "course_id and valid cefr_level are required"}) + } + created, err := h.analyticsDB.CreateLevel(c.Context(), dbgen.CreateLevelParams{ + CourseID: req.CourseID, + CefrLevel: req.CEFRLevel, + Column3: intOrNil(req.DisplayOrder), + Column4: boolOrNil(req.IsActive), + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create level", Error: err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Level created", Data: created}) +} + +// CreateModule godoc +// @Summary Create module +// @Description Creates a module under a level +// @Tags course-management +// @Accept json +// @Produce json +// @Param body body createModuleReq true "Create module payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/modules [post] +func (h *Handler) CreateModule(c *fiber.Ctx) error { + var req createModuleReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + if req.LevelID <= 0 || strings.TrimSpace(req.Title) == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "level_id and title are required"}) + } + created, err := h.analyticsDB.CreateModule(c.Context(), dbgen.CreateModuleParams{ + LevelID: req.LevelID, + Title: req.Title, + Description: toText(req.Description), + Column4: intOrNil(req.DisplayOrder), + Column5: boolOrNil(req.IsActive), + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create module", Error: err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Module created", Data: created}) +} + +// CreateSubModule godoc +// @Summary Create sub-module +// @Description Creates a sub-module under a module +// @Tags course-management +// @Accept json +// @Produce json +// @Param body body createSubModuleReq true "Create sub-module payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-modules [post] +func (h *Handler) CreateSubModule(c *fiber.Ctx) error { + var req createSubModuleReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + if req.ModuleID <= 0 || strings.TrimSpace(req.Title) == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "module_id and title are required"}) + } + created, err := h.analyticsDB.CreateSubModule(c.Context(), dbgen.CreateSubModuleParams{ + ModuleID: req.ModuleID, + Title: req.Title, + Description: toText(req.Description), + Column4: intOrNil(req.DisplayOrder), + Column5: boolOrNil(req.IsActive), + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module", Error: err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module created", Data: created}) +} + +// CreateSubModuleVideo godoc +// @Summary Create sub-module video +// @Description Creates a video under a sub-module +// @Tags course-management +// @Accept json +// @Produce json +// @Param body body createSubModuleVideoReq true "Create sub-module video payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-module-videos [post] +func (h *Handler) CreateSubModuleVideo(c *fiber.Ctx) error { + var req createSubModuleVideoReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + if req.SubModuleID <= 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.VideoURL) == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id, title and video_url are required"}) + } + created, err := h.analyticsDB.CreateSubModuleVideo(c.Context(), dbgen.CreateSubModuleVideoParams{ + SubModuleID: req.SubModuleID, + Title: req.Title, + Description: toText(req.Description), + VideoUrl: req.VideoURL, + Duration: toInt4(req.Duration), + Resolution: toText(req.Resolution), + Column7: nil, + Visibility: toText(req.Visibility), + InstructorID: toText(req.InstructorID), + Thumbnail: toText(req.Thumbnail), + Column12: intOrNil(req.DisplayOrder), + Column13: req.Status, + VimeoID: pgtype.Text{Valid: false}, + VimeoEmbedUrl: pgtype.Text{Valid: false}, + VimeoPlayerHtml: pgtype.Text{Valid: false}, + VimeoStatus: pgtype.Text{Valid: false}, + Column18: nil, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module video", Error: err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module video created", Data: created}) +} + +// AttachSubModuleLesson godoc +// @Summary Attach lesson to sub-module +// @Description Links a question set lesson to a sub-module +// @Tags course-management +// @Accept json +// @Produce json +// @Param body body attachSubModuleLessonReq true "Attach lesson payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-module-lessons [post] +func (h *Handler) AttachSubModuleLesson(c *fiber.Ctx) error { + var req attachSubModuleLessonReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + if req.SubModuleID <= 0 || req.QuestionSetID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id and question_set_id are required"}) + } + attached, err := h.analyticsDB.AttachQuestionSetLessonToSubModule(c.Context(), dbgen.AttachQuestionSetLessonToSubModuleParams{ + SubModuleID: req.SubModuleID, + QuestionSetID: req.QuestionSetID, + IntroVideoUrl: toText(req.IntroVideoURL), + Column4: intOrNil(req.DisplayOrder), + Column5: boolOrNil(req.IsActive), + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to attach lesson", Error: err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Lesson attached to sub-module", Data: attached}) +} + +// CreateSubModulePractice godoc +// @Summary Create practice under sub-module +// @Description Creates a sub-module practice with metadata and linked question set +// @Tags course-management +// @Accept json +// @Produce json +// @Param body body createSubModulePracticeReq true "Create practice payload" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/course-management/sub-module-practices [post] +func (h *Handler) CreateSubModulePractice(c *fiber.Ctx) error { + var req createSubModulePracticeReq + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) + } + if req.SubModuleID <= 0 || req.QuestionSetID <= 0 || strings.TrimSpace(req.Title) == "" { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id, title and question_set_id are required"}) + } + created, err := h.analyticsDB.CreateSubModulePractice(c.Context(), dbgen.CreateSubModulePracticeParams{ + SubModuleID: req.SubModuleID, + Title: req.Title, + Description: toText(req.Description), + Thumbnail: toText(req.Thumbnail), + IntroVideoUrl: toText(req.IntroVideoURL), + QuestionSetID: req.QuestionSetID, + Column7: intOrNil(req.DisplayOrder), + Column8: boolOrNil(req.IsActive), + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create practice", Error: err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Practice created", Data: created}) +} + diff --git a/internal/web_server/handlers/progression_handler.go b/internal/web_server/handlers/progression_handler.go deleted file mode 100644 index c4abc7f..0000000 --- a/internal/web_server/handlers/progression_handler.go +++ /dev/null @@ -1,541 +0,0 @@ -package handlers - -import ( - "Yimaru-Backend/internal/domain" - "errors" - "math" - "strconv" - "time" - - "github.com/gofiber/fiber/v2" -) - -// --- Request / Response types --- - -type addPrerequisiteReq struct { - PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id" validate:"required"` -} - -type prerequisiteRes struct { - ID int64 `json:"id"` - SubCourseID int64 `json:"sub_course_id"` - PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` - PrerequisiteTitle string `json:"prerequisite_title"` - PrerequisiteLevel string `json:"prerequisite_level"` - PrerequisiteDisplayOrder int32 `json:"prerequisite_display_order"` -} - -type dependentRes struct { - ID int64 `json:"id"` - SubCourseID int64 `json:"sub_course_id"` - PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` - DependentTitle string `json:"dependent_title"` - DependentLevel string `json:"dependent_level"` -} - -type updateProgressReq struct { - ProgressPercentage int16 `json:"progress_percentage" validate:"required,min=0,max=100"` -} - -type subCourseProgressRes struct { - SubCourseID int64 `json:"sub_course_id"` - Title string `json:"title"` - Description *string `json:"description,omitempty"` - Thumbnail *string `json:"thumbnail,omitempty"` - DisplayOrder int32 `json:"display_order"` - Level string `json:"level"` - ProgressStatus string `json:"progress_status"` - ProgressPercentage int16 `json:"progress_percentage"` - StartedAt *time.Time `json:"started_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - IsLocked bool `json:"is_locked"` -} - -type userProgressRes struct { - SubCourseID int64 `json:"sub_course_id"` - SubCourseTitle string `json:"sub_course_title"` - SubCourseLevel string `json:"sub_course_level"` - Status string `json:"status"` - ProgressPercentage int16 `json:"progress_percentage"` - StartedAt *time.Time `json:"started_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` -} - -type courseProgressSummaryRes struct { - CourseID int64 `json:"course_id"` - LearnerUserID int64 `json:"learner_user_id"` - OverallProgressPercentage int16 `json:"overall_progress_percentage"` - TotalSubCourses int32 `json:"total_sub_courses"` - CompletedSubCourses int32 `json:"completed_sub_courses"` - InProgressSubCourses int32 `json:"in_progress_sub_courses"` - NotStartedSubCourses int32 `json:"not_started_sub_courses"` - LockedSubCourses int32 `json:"locked_sub_courses"` -} - -func mapSubCourseProgress(items []domain.SubCourseWithProgress) []subCourseProgressRes { - res := make([]subCourseProgressRes, 0, len(items)) - for _, item := range items { - res = append(res, subCourseProgressRes{ - SubCourseID: item.SubCourseID, - Title: item.Title, - Description: item.Description, - Thumbnail: item.Thumbnail, - DisplayOrder: item.DisplayOrder, - Level: item.Level, - ProgressStatus: string(item.ProgressStatus), - ProgressPercentage: item.ProgressPercentage, - StartedAt: item.StartedAt, - CompletedAt: item.CompletedAt, - IsLocked: item.IsLocked, - }) - } - return res -} - -// --- Prerequisite Handlers (admin) --- - -// AddSubCoursePrerequisite godoc -// @Summary Add prerequisite to sub-course -// @Description Link a prerequisite sub-course that must be completed before accessing this sub-course -// @Tags progression -// @Accept json -// @Produce json -// @Param id path int true "Sub-course ID" -// @Param body body addPrerequisiteReq true "Prerequisite sub-course ID" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-courses/{id}/prerequisites [post] -func (h *Handler) AddSubCoursePrerequisite(c *fiber.Ctx) error { - subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - var req addPrerequisiteReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - if err := h.courseMgmtSvc.AddSubCoursePrerequisite(c.Context(), subCourseID, req.PrerequisiteSubCourseID); err != nil { - if errors.Is(err, domain.ErrSelfPrerequisite) { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid prerequisite", - Error: err.Error(), - }) - } - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to add prerequisite", - Error: err.Error(), - }) - } - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Prerequisite added successfully", - }) -} - -// GetSubCoursePrerequisites godoc -// @Summary Get sub-course prerequisites -// @Description Returns all prerequisites for a sub-course -// @Tags progression -// @Produce json -// @Param id path int true "Sub-course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-courses/{id}/prerequisites [get] -func (h *Handler) GetSubCoursePrerequisites(c *fiber.Ctx) error { - subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - prerequisites, err := h.courseMgmtSvc.GetSubCoursePrerequisites(c.Context(), subCourseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to get prerequisites", - Error: err.Error(), - }) - } - - var res []prerequisiteRes - for _, p := range prerequisites { - res = append(res, prerequisiteRes{ - ID: p.ID, - SubCourseID: p.SubCourseID, - PrerequisiteSubCourseID: p.PrerequisiteSubCourseID, - PrerequisiteTitle: p.PrerequisiteTitle, - PrerequisiteLevel: p.PrerequisiteLevel, - PrerequisiteDisplayOrder: p.PrerequisiteDisplayOrder, - }) - } - - return c.JSON(domain.Response{ - Message: "Prerequisites retrieved successfully", - Data: res, - }) -} - -// RemoveSubCoursePrerequisite godoc -// @Summary Remove prerequisite from sub-course -// @Description Unlink a prerequisite from a sub-course -// @Tags progression -// @Produce json -// @Param id path int true "Sub-course ID" -// @Param prerequisiteId path int true "Prerequisite sub-course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-courses/{id}/prerequisites/{prerequisiteId} [delete] -func (h *Handler) RemoveSubCoursePrerequisite(c *fiber.Ctx) error { - subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - prerequisiteID, err := strconv.ParseInt(c.Params("prerequisiteId"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid prerequisite ID", - Error: err.Error(), - }) - } - - if err := h.courseMgmtSvc.RemoveSubCoursePrerequisite(c.Context(), subCourseID, prerequisiteID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to remove prerequisite", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Prerequisite removed successfully", - }) -} - -// --- User Progress Handlers --- - -// StartSubCourse godoc -// @Summary Start a sub-course -// @Description Mark a sub-course as started for the authenticated user (checks prerequisites) -// @Tags progression -// @Produce json -// @Param id path int true "Sub-course ID" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 403 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/progress/sub-courses/{id}/start [post] -func (h *Handler) StartSubCourse(c *fiber.Ctx) error { - userID := c.Locals("user_id").(int64) - subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - progress, err := h.courseMgmtSvc.StartSubCourse(c.Context(), userID, subCourseID) - if err != nil { - if errors.Is(err, domain.ErrPrerequisiteNotMet) { - return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ - Message: "Cannot start sub-course", - Error: "Prerequisites not completed", - }) - } - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to start sub-course", - Error: err.Error(), - }) - } - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Sub-course started", - Data: userProgressRes{ - SubCourseID: progress.SubCourseID, - Status: string(progress.Status), - ProgressPercentage: progress.ProgressPercentage, - StartedAt: progress.StartedAt, - }, - }) -} - -// UpdateSubCourseProgress godoc -// @Summary Update sub-course progress -// @Description Update the progress percentage for a sub-course -// @Tags progression -// @Accept json -// @Produce json -// @Param id path int true "Sub-course ID" -// @Param body body updateProgressReq true "Progress update" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/progress/sub-courses/{id} [put] -func (h *Handler) UpdateSubCourseProgress(c *fiber.Ctx) error { - userID := c.Locals("user_id").(int64) - subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - var req updateProgressReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - if err := h.courseMgmtSvc.UpdateSubCourseProgress(c.Context(), userID, subCourseID, req.ProgressPercentage); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update progress", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Progress updated successfully", - }) -} - -// CompleteSubCourse godoc -// @Summary Complete a sub-course -// @Description Mark a sub-course as completed for the authenticated user -// @Tags progression -// @Produce json -// @Param id path int true "Sub-course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/progress/sub-courses/{id}/complete [post] -func (h *Handler) CompleteSubCourse(c *fiber.Ctx) error { - userID := c.Locals("user_id").(int64) - subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - if err := h.courseMgmtSvc.CompleteSubCourse(c.Context(), userID, subCourseID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to complete sub-course", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Sub-course completed", - }) -} - -// CheckSubCourseAccess godoc -// @Summary Check sub-course access -// @Description Check if the authenticated user has completed all prerequisites for a sub-course -// @Tags progression -// @Produce json -// @Param id path int true "Sub-course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/progress/sub-courses/{id}/access [get] -func (h *Handler) CheckSubCourseAccess(c *fiber.Ctx) error { - userID := c.Locals("user_id").(int64) - subCourseID, err := strconv.ParseInt(c.Params("id"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - accessible, err := h.courseMgmtSvc.CheckSubCourseAccess(c.Context(), userID, subCourseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to check access", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Access check completed", - Data: fiber.Map{ - "accessible": accessible, - }, - }) -} - -// GetUserCourseProgress godoc -// @Summary Get user's course progress -// @Description Returns the authenticated user's progress for all sub-courses in a course, including lock status -// @Tags progression -// @Produce json -// @Param courseId path int true "Course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/progress/courses/{courseId} [get] -func (h *Handler) GetUserCourseProgress(c *fiber.Ctx) error { - userID := c.Locals("user_id").(int64) - courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid course ID", - Error: err.Error(), - }) - } - - items, err := h.courseMgmtSvc.GetSubCoursesWithProgress(c.Context(), userID, courseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to get course progress", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Course progress retrieved successfully", - Data: mapSubCourseProgress(items), - }) -} - -// GetUserCourseProgressForAdmin godoc -// @Summary Get learner's course progress (admin) -// @Description Returns a target learner's progress for all sub-courses in a course, including lock status -// @Tags progression -// @Produce json -// @Param userId path int true "Learner User ID" -// @Param courseId path int true "Course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/admin/users/{userId}/progress/courses/{courseId} [get] -func (h *Handler) GetUserCourseProgressForAdmin(c *fiber.Ctx) error { - targetUserID, err := strconv.ParseInt(c.Params("userId"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid user ID", - Error: err.Error(), - }) - } - - courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid course ID", - Error: err.Error(), - }) - } - - items, err := h.courseMgmtSvc.GetSubCoursesWithProgress(c.Context(), targetUserID, courseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to get learner course progress", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Learner course progress retrieved successfully", - Data: mapSubCourseProgress(items), - }) -} - -// GetUserCourseProgressSummaryForAdmin godoc -// @Summary Get learner's course progress summary (admin) -// @Description Returns course-level aggregated progress metrics for a target learner -// @Tags progression -// @Produce json -// @Param userId path int true "Learner User ID" -// @Param courseId path int true "Course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/admin/users/{userId}/progress/courses/{courseId}/summary [get] -func (h *Handler) GetUserCourseProgressSummaryForAdmin(c *fiber.Ctx) error { - targetUserID, err := strconv.ParseInt(c.Params("userId"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid user ID", - Error: err.Error(), - }) - } - - courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid course ID", - Error: err.Error(), - }) - } - - items, err := h.courseMgmtSvc.GetSubCoursesWithProgress(c.Context(), targetUserID, courseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to get learner course progress summary", - Error: err.Error(), - }) - } - - var ( - completedCount int32 - inProgressCount int32 - notStartedCount int32 - lockedCount int32 - sumPercentage int64 - ) - - for _, item := range items { - sumPercentage += int64(item.ProgressPercentage) - switch item.ProgressStatus { - case domain.ProgressStatusCompleted: - completedCount++ - case domain.ProgressStatusInProgress: - inProgressCount++ - default: - notStartedCount++ - } - if item.IsLocked { - lockedCount++ - } - } - - totalSubCourses := int32(len(items)) - overall := int16(0) - if totalSubCourses > 0 { - overall = int16(math.Round(float64(sumPercentage) / float64(totalSubCourses))) - } - - return c.JSON(domain.Response{ - Message: "Learner course progress summary retrieved successfully", - Data: courseProgressSummaryRes{ - CourseID: courseID, - LearnerUserID: targetUserID, - OverallProgressPercentage: overall, - TotalSubCourses: totalSubCourses, - CompletedSubCourses: completedCount, - InProgressSubCourses: inProgressCount, - NotStartedSubCourses: notStartedCount, - LockedSubCourses: lockedCount, - }, - }) -} diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 110af42..e6715a6 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -1350,19 +1350,6 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error { Error: err.Error(), }) } - if set.OwnerID == nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update sub-course progress", - Error: "practice owner is missing", - }) - } - if err := h.courseMgmtSvc.RecalculateSubCourseProgress(c.Context(), userID, *set.OwnerID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update sub-course progress", - Error: err.Error(), - }) - } - return c.JSON(domain.Response{ Message: "Practice completed", }) diff --git a/internal/web_server/handlers/thumbnail_helper.go b/internal/web_server/handlers/thumbnail_helper.go new file mode 100644 index 0000000..852409b --- /dev/null +++ b/internal/web_server/handlers/thumbnail_helper.go @@ -0,0 +1,85 @@ +package handlers + +import ( + "bytes" + "io" + "net/http" + "os" + "path/filepath" + + "Yimaru-Backend/internal/domain" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "go.uber.org/zap" +) + +func (h *Handler) processAndSaveThumbnail(c *fiber.Ctx, subDir string) (string, error) { + fileHeader, err := c.FormFile("file") + if err != nil { + return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Image file is required", Error: err.Error()}) + } + if fileHeader.Size > 10*1024*1024 { + return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "File too large", Error: "Thumbnail image must be <= 10MB"}) + } + + fh, err := fileHeader.Open() + if err != nil { + return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to read file", Error: err.Error()}) + } + defer fh.Close() + + head := make([]byte, 512) + n, _ := fh.Read(head) + contentType := http.DetectContentType(head[:n]) + if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" { + return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid file type", Error: "Only jpg, png and webp images are allowed"}) + } + + rest, err := io.ReadAll(fh) + if err != nil { + return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to read file", Error: err.Error()}) + } + data := append(head[:n], rest...) + + if h.cloudConvertSvc != nil { + optimized, optErr := h.cloudConvertSvc.OptimizeImage(c.Context(), fileHeader.Filename, bytes.NewReader(data), int64(len(data)), 1200, 80) + if optErr != nil { + h.mongoLoggerSvc.Warn("CloudConvert thumbnail optimization failed, using original", zap.Error(optErr)) + } else { + optimizedData, readErr := io.ReadAll(optimized.Data) + optimized.Data.Close() + if readErr == nil { + data = optimizedData + contentType = "image/webp" + } + } + } + + if h.minioSvc != nil { + result, uploadErr := h.minioSvc.Upload(c.Context(), subDir, fileHeader.Filename, bytes.NewReader(data), int64(len(data)), contentType) + if uploadErr != nil { + return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to upload file to storage", Error: uploadErr.Error()}) + } + return "minio://" + result.ObjectKey, nil + } + + ext := ".jpg" + if contentType == "image/png" { + ext = ".png" + } + if contentType == "image/webp" { + ext = ".webp" + } + dir := filepath.Join(".", "static", subDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create storage directory", Error: err.Error()}) + } + filename := uuid.New().String() + ext + fullpath := filepath.Join(dir, filename) + if err := os.WriteFile(fullpath, data, 0o644); err != nil { + return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to save file", Error: err.Error()}) + } + return "/static/" + subDir + "/" + filename, nil +} + diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 56d425c..a278acb 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -78,60 +78,16 @@ func (a *App) initAppRoutes() { groupV1.Get("/assessment/questions", h.ListAssessmentQuestions) groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID) - // Reorder (drag-and-drop support) - // Keep static reorder routes before dynamic `/:id` routes to avoid route collisions - // (e.g., `/courses/reorder` being parsed as `/courses/:id`). - groupV1.Put("/course-management/categories/reorder", a.authMiddleware, a.RequirePermission("course_categories.reorder"), h.ReorderCourseCategories) - groupV1.Put("/course-management/courses/reorder", a.authMiddleware, a.RequirePermission("courses.reorder"), h.ReorderCourses) - groupV1.Put("/course-management/sub-courses/reorder", a.authMiddleware, a.RequirePermission("subcourses.reorder"), h.ReorderSubCourses) - groupV1.Put("/course-management/videos/reorder", a.authMiddleware, a.RequirePermission("videos.reorder"), h.ReorderSubCourseVideos) - groupV1.Put("/course-management/practices/reorder", a.authMiddleware, a.RequirePermission("practices.reorder"), h.ReorderPractices) - - // Course Categories - groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory) - groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.list"), h.GetAllCourseCategories) - groupV1.Get("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.get"), h.GetCourseCategoryByID) - groupV1.Put("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.update"), h.UpdateCourseCategory) - groupV1.Delete("/course-management/categories/:id", a.authMiddleware, a.RequirePermission("course_categories.delete"), h.DeleteCourseCategory) - - // Courses - groupV1.Post("/course-management/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse) - groupV1.Get("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.get"), h.GetCourseByID) - groupV1.Get("/course-management/categories/:categoryId/courses", a.authMiddleware, a.RequirePermission("courses.list_by_category"), h.GetCoursesByCategory) - groupV1.Put("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse) - groupV1.Post("/course-management/courses/:id/thumbnail", a.authMiddleware, a.RequirePermission("courses.upload_thumbnail"), h.UploadCourseThumbnail) - groupV1.Delete("/course-management/courses/:id", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse) - - // Sub-courses - groupV1.Post("/course-management/sub-courses", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubCourse) - groupV1.Get("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.get"), h.GetSubCourseByID) - groupV1.Get("/course-management/courses/:courseId/sub-courses", a.authMiddleware, a.RequirePermission("subcourses.list_by_course"), h.GetSubCoursesByCourse) - groupV1.Get("/course-management/courses/:courseId/sub-courses/list", a.authMiddleware, a.RequirePermission("subcourses.list_by_course_list"), h.ListSubCoursesByCourse) - groupV1.Get("/course-management/sub-courses/active", a.authMiddleware, a.RequirePermission("subcourses.list_active"), h.ListActiveSubCourses) - groupV1.Patch("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateSubCourse) - groupV1.Post("/course-management/sub-courses/:id/thumbnail", a.authMiddleware, a.RequirePermission("subcourses.upload_thumbnail"), h.UploadSubCourseThumbnail) - groupV1.Put("/course-management/sub-courses/:id/deactivate", a.authMiddleware, a.RequirePermission("subcourses.deactivate"), h.DeactivateSubCourse) - groupV1.Delete("/course-management/sub-courses/:id", a.authMiddleware, a.RequirePermission("subcourses.delete"), h.DeleteSubCourse) - - // Sub-course Videos - groupV1.Post("/course-management/videos", a.authMiddleware, a.RequirePermission("videos.create"), h.CreateSubCourseVideo) - groupV1.Post("/course-management/videos/vimeo", a.authMiddleware, a.RequirePermission("videos.create_vimeo"), h.CreateSubCourseVideoWithVimeo) - groupV1.Post("/course-management/videos/upload", a.authMiddleware, a.RequirePermission("videos.upload"), h.UploadSubCourseVideo) - groupV1.Post("/course-management/videos/vimeo/import", a.authMiddleware, a.RequirePermission("videos.import_vimeo"), h.CreateSubCourseVideoFromVimeoID) - groupV1.Get("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.get"), h.GetSubCourseVideoByID) - groupV1.Get("/course-management/sub-courses/:subCourseId/videos", a.authMiddleware, a.RequirePermission("videos.list_by_subcourse"), h.GetVideosBySubCourse) - groupV1.Get("/course-management/sub-courses/:subCourseId/videos/published", a.authMiddleware, a.RequirePermission("videos.list_published"), h.GetPublishedVideosBySubCourse) - groupV1.Put("/course-management/videos/:id/publish", a.authMiddleware, a.RequirePermission("videos.publish"), h.PublishSubCourseVideo) - groupV1.Put("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.update"), h.UpdateSubCourseVideo) - groupV1.Delete("/course-management/videos/:id", a.authMiddleware, a.RequirePermission("videos.delete"), h.DeleteSubCourseVideo) - - // Learning Tree - groupV1.Get("/course-management/learning-tree", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetFullLearningTree) - groupV1.Get("/course-management/courses/:courseId/learning-path", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetCourseLearningPath) - groupV1.Get("/course-management/human-language/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetHumanLanguageHierarchy) - groupV1.Get("/course-management/human-language/courses/:courseId/lessons", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetHumanLanguageLessonsByCourse) - groupV1.Post("/course-management/human-language/lessons", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateHumanLanguageLesson) - groupV1.Patch("/course-management/human-language/lessons/:id", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateHumanLanguageLesson) + // Unified Course Management (single hierarchy model) + groupV1.Get("/course-management/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchy) + groupV1.Get("/course-management/courses/:courseId/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchyByCourse) + groupV1.Post("/course-management/sub-categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseSubCategory) + groupV1.Post("/course-management/levels", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateLevel) + groupV1.Post("/course-management/modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateModule) + groupV1.Post("/course-management/sub-modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubModule) + groupV1.Post("/course-management/sub-module-videos", a.authMiddleware, a.RequirePermission("videos.create"), h.CreateSubModuleVideo) + groupV1.Post("/course-management/sub-module-lessons", a.authMiddleware, a.RequirePermission("question_sets.update"), h.AttachSubModuleLesson) + groupV1.Post("/course-management/sub-module-practices", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateSubModulePractice) // Questions groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion) @@ -145,7 +101,6 @@ 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) @@ -336,21 +291,7 @@ func (a *App) initAppRoutes() { teamGroup.Delete("/members/:id", a.authMiddleware, a.RequirePermission("team.members.delete"), h.DeleteTeamMember) teamGroup.Post("/members/:id/change-password", a.authMiddleware, a.RequirePermission("team.members.change_password"), h.ChangeTeamMemberPassword) - // Sub-course Prerequisites - groupV1.Post("/course-management/sub-courses/:id/prerequisites", a.authMiddleware, a.RequirePermission("subcourse_prerequisites.add"), h.AddSubCoursePrerequisite) - groupV1.Get("/course-management/sub-courses/:id/prerequisites", a.authMiddleware, a.RequirePermission("subcourse_prerequisites.list"), h.GetSubCoursePrerequisites) - groupV1.Delete("/course-management/sub-courses/:id/prerequisites/:prerequisiteId", a.authMiddleware, a.RequirePermission("subcourse_prerequisites.remove"), h.RemoveSubCoursePrerequisite) - - // User Progression - 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) - groupV1.Get("/admin/users/:userId/progress/courses/:courseId", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.GetUserCourseProgressForAdmin) - groupV1.Get("/admin/users/:userId/progress/courses/:courseId/summary", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.GetUserCourseProgressSummaryForAdmin) + // Legacy sub-course prerequisite/progression routes removed after hierarchy cutover. // Ratings groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating)