From 152478a96c7925143051329c3902e3edbfc7110d Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Thu, 23 Apr 2026 00:59:01 -0700 Subject: [PATCH] added program --- cmd/main.go | 23 +- db/data/007_course_management_seed.sql | 2 - ...1_remove_course_management_schema.down.sql | 1 + ...041_remove_course_management_schema.up.sql | 46 + db/migrations/000042_programs.down.sql | 1 + db/migrations/000042_programs.up.sql | 11 + db/query/analytics.sql | 8 +- db/query/course_catagories.sql | 47 - db/query/courses.sql | 109 - db/query/hierarchy.sql | 586 --- db/query/practice_progress.sql | 17 +- db/query/programs.sql | 36 + db/query/question_sets.sql | 23 +- .../COURSE_MANAGEMENT_POSTMAN_COLLECTION.json | 825 ----- gen/db/analytics.sql.go | 8 +- gen/db/compat_course_management.go | 227 -- gen/db/course_catagories.sql.go | 158 - gen/db/courses.sql.go | 401 --- gen/db/hierarchy.sql.go | 2179 ----------- gen/db/models.go | 237 +- gen/db/practice_progress.sql.go | 16 +- gen/db/programs.sql.go | 162 + gen/db/question_set_items.sql.go | 3 +- gen/db/question_sets.sql.go | 83 +- internal/domain/activity_log.go | 4 + internal/domain/course_management.go | 170 - internal/domain/program.go | 25 + internal/domain/progression.go | 84 - internal/domain/questions.go | 2 - internal/ports/course_management.go | 216 -- internal/ports/program.go | 14 + internal/ports/questions.go | 1 - internal/repository/course_catagories.go | 128 - internal/repository/courses.go | 173 - internal/repository/programs.go | 112 + internal/repository/questions.go | 12 - .../services/course_management/service.go | 51 - internal/services/programs/service.go | 69 + internal/services/questions/service.go | 4 - internal/services/rbac/seeds.go | 14 + internal/web_server/app.go | 109 +- internal/web_server/handlers/handlers.go | 16 +- .../web_server/handlers/hierarchy_handler.go | 3191 ----------------- .../handlers/maintenance_handler.go | 277 -- .../web_server/handlers/program_handler.go | 227 ++ internal/web_server/handlers/questions.go | 91 +- internal/web_server/routes.go | 75 +- 47 files changed, 810 insertions(+), 9464 deletions(-) delete mode 100644 db/data/007_course_management_seed.sql create mode 100644 db/migrations/000041_remove_course_management_schema.down.sql create mode 100644 db/migrations/000041_remove_course_management_schema.up.sql create mode 100644 db/migrations/000042_programs.down.sql create mode 100644 db/migrations/000042_programs.up.sql delete mode 100644 db/query/course_catagories.sql delete mode 100644 db/query/courses.sql delete mode 100644 db/query/hierarchy.sql create mode 100644 db/query/programs.sql delete mode 100644 docs/COURSE_MANAGEMENT_POSTMAN_COLLECTION.json delete mode 100644 gen/db/compat_course_management.go delete mode 100644 gen/db/course_catagories.sql.go delete mode 100644 gen/db/courses.sql.go delete mode 100644 gen/db/hierarchy.sql.go create mode 100644 gen/db/programs.sql.go delete mode 100644 internal/domain/course_management.go create mode 100644 internal/domain/program.go delete mode 100644 internal/domain/progression.go delete mode 100644 internal/ports/course_management.go create mode 100644 internal/ports/program.go delete mode 100644 internal/repository/course_catagories.go delete mode 100644 internal/repository/courses.go create mode 100644 internal/repository/programs.go delete mode 100644 internal/services/course_management/service.go create mode 100644 internal/services/programs/service.go delete mode 100644 internal/web_server/handlers/hierarchy_handler.go delete mode 100644 internal/web_server/handlers/maintenance_handler.go create mode 100644 internal/web_server/handlers/program_handler.go diff --git a/cmd/main.go b/cmd/main.go index b8ee732..2be5d47 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -14,10 +14,10 @@ import ( "Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" - "Yimaru-Backend/internal/services/course_management" issuereporting "Yimaru-Backend/internal/services/issue_reporting" "Yimaru-Backend/internal/services/messenger" notificationservice "Yimaru-Backend/internal/services/notification" + programsservice "Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/settings" @@ -360,24 +360,10 @@ func main() { logger.Info("Vimeo service disabled (VIMEO_ENABLED not set or missing access token)") } - // Course management service - courseSvc := course_management.NewService( - repository.NewUserStore(store), - repository.NewCourseStore(store), - repository.NewProgressionStore(store), - notificationSvc, - cfg, - ) - // Wire up Vimeo service to course management - if vimeoSvc != nil { - courseSvc.SetVimeoService(vimeoSvc) - } - - // CloudConvert service for video compression + // CloudConvert service for image/video optimization var ccSvc *cloudconvertservice.Service if cfg.CloudConvert.Enabled && cfg.CloudConvert.APIKey != "" { ccSvc = cloudconvertservice.NewService(cfg.CloudConvert.APIKey, domain.MongoDBLogger) - courseSvc.SetCloudConvertService(ccSvc) logger.Info("CloudConvert service initialized") } else { logger.Info("CloudConvert service disabled (CLOUDCONVERT_ENABLED not set or missing API key)") @@ -402,6 +388,9 @@ func main() { // Questions service (unified questions system) questionsSvc := questions.NewService(store) + // LMS programs (top-level hierarchy) + programSvc := programsservice.NewService(store) + // Subscriptions service subscriptionsSvc := subscriptions.NewService(store) @@ -442,8 +431,8 @@ func main() { // Initialize and start HTTP server app := httpserver.NewApp( assessmentSvc, - courseSvc, questionsSvc, + programSvc, subscriptionsSvc, arifpaySvc, issueReportingSvc, diff --git a/db/data/007_course_management_seed.sql b/db/data/007_course_management_seed.sql deleted file mode 100644 index 80cc0d3..0000000 --- a/db/data/007_course_management_seed.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Intentionally empty: course hierarchy is not seeded from SQL. --- Use admin/API or migrations to create content. diff --git a/db/migrations/000041_remove_course_management_schema.down.sql b/db/migrations/000041_remove_course_management_schema.down.sql new file mode 100644 index 0000000..adbaacf --- /dev/null +++ b/db/migrations/000041_remove_course_management_schema.down.sql @@ -0,0 +1 @@ +-- Restoring the removed course hierarchy is not supported; apply new migrations for the next model. diff --git a/db/migrations/000041_remove_course_management_schema.up.sql b/db/migrations/000041_remove_course_management_schema.up.sql new file mode 100644 index 0000000..812b1c2 --- /dev/null +++ b/db/migrations/000041_remove_course_management_schema.up.sql @@ -0,0 +1,46 @@ +-- Tear down the legacy course / learning-tree schema so a new hierarchy can be introduced. +BEGIN; + +-- Entry-assessment automation on sub_courses (from 000024) +DROP TRIGGER IF EXISTS trg_sub_courses_create_entry_assessment ON sub_courses; +DROP FUNCTION IF EXISTS create_sub_course_entry_assessment(); +DROP FUNCTION IF EXISTS clone_default_initial_assessment_items(BIGINT); +DROP INDEX IF EXISTS idx_question_sets_unique_subcourse_initial_assessment; + +ALTER TABLE question_sets DROP COLUMN IF EXISTS sub_course_video_id; + +-- Dependent objects first +DROP TABLE IF EXISTS user_sub_course_video_progress CASCADE; +DROP TABLE IF EXISTS user_practice_progress CASCADE; +DROP TABLE IF EXISTS sub_course_prerequisites CASCADE; +DROP TABLE IF EXISTS user_sub_course_progress CASCADE; + +DROP TABLE IF EXISTS sub_module_practices CASCADE; +DROP TABLE IF EXISTS sub_module_capstones CASCADE; +DROP TABLE IF EXISTS sub_module_lessons CASCADE; +DROP TABLE IF EXISTS sub_module_videos CASCADE; +DROP TABLE IF EXISTS sub_modules CASCADE; +DROP TABLE IF EXISTS module_capstones CASCADE; +DROP TABLE IF EXISTS modules CASCADE; +DROP TABLE IF EXISTS levels CASCADE; + +DROP TABLE IF EXISTS sub_course_videos CASCADE; +DROP TABLE IF EXISTS sub_courses CASCADE; +DROP TABLE IF EXISTS course_sub_categories CASCADE; +DROP TABLE IF EXISTS courses CASCADE; +DROP TABLE IF EXISTS course_categories CASCADE; + +-- Keep learner practice completion for the questions system (no sub_course column) +CREATE TABLE user_practice_progress ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question_set_id BIGINT NOT NULL REFERENCES question_sets(id) ON DELETE CASCADE, + completed_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, question_set_id) +); + +CREATE INDEX idx_user_practice_progress_user_id ON user_practice_progress(user_id); + +COMMIT; diff --git a/db/migrations/000042_programs.down.sql b/db/migrations/000042_programs.down.sql new file mode 100644 index 0000000..e01d4ca --- /dev/null +++ b/db/migrations/000042_programs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS programs; diff --git a/db/migrations/000042_programs.up.sql b/db/migrations/000042_programs.up.sql new file mode 100644 index 0000000..5e5ce69 --- /dev/null +++ b/db/migrations/000042_programs.up.sql @@ -0,0 +1,11 @@ +-- Top-level LMS program (e.g. Beginner / Intermediate / Advanced — labels come from admin config later). +CREATE TABLE programs ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + thumbnail TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ +); + +CREATE INDEX idx_programs_created_at ON programs (created_at DESC); diff --git a/db/query/analytics.sql b/db/query/analytics.sql index 727ac17..79af4ef 100644 --- a/db/query/analytics.sql +++ b/db/query/analytics.sql @@ -163,10 +163,10 @@ ORDER BY d.date; -- name: AnalyticsCourseCounts :one SELECT - (SELECT COUNT(*)::bigint FROM course_categories) AS total_categories, - (SELECT COUNT(*)::bigint FROM courses) AS total_courses, - (SELECT COUNT(*)::bigint FROM sub_courses) AS total_sub_courses, - (SELECT COUNT(*)::bigint FROM sub_course_videos) AS total_videos; + 0::bigint AS total_categories, + 0::bigint AS total_courses, + 0::bigint AS total_sub_courses, + 0::bigint AS total_videos; -- ===================== -- Content Analytics diff --git a/db/query/course_catagories.sql b/db/query/course_catagories.sql deleted file mode 100644 index 2eb530f..0000000 --- a/db/query/course_catagories.sql +++ /dev/null @@ -1,47 +0,0 @@ --- name: CreateCourseCategory :one -INSERT INTO course_categories ( - name, - is_active -) -VALUES ($1, COALESCE($2, true)) -RETURNING *; - - --- name: GetCourseCategoryByID :one -SELECT * -FROM course_categories -WHERE id = $1; - - --- name: GetAllCourseCategories :many -SELECT - COUNT(*) OVER () AS total_count, - id, - name, - is_active, - created_at -FROM course_categories -ORDER BY display_order ASC, created_at DESC -LIMIT sqlc.narg('limit')::INT -OFFSET sqlc.narg('offset')::INT; - - --- name: UpdateCourseCategory :exec -UPDATE course_categories -SET - name = COALESCE($1, name), - is_active = COALESCE($2, is_active) -WHERE id = $3; - - --- name: DeleteCourseCategory :exec -DELETE FROM course_categories -WHERE id = $1; - --- name: ReorderCourseCategories :exec -UPDATE course_categories -SET display_order = bulk.position -FROM ( - SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position -) AS bulk -WHERE course_categories.id = bulk.id; diff --git a/db/query/courses.sql b/db/query/courses.sql deleted file mode 100644 index 7aab0c0..0000000 --- a/db/query/courses.sql +++ /dev/null @@ -1,109 +0,0 @@ --- name: CreateCourse :one -INSERT INTO courses ( - category_id, - title, - description, - thumbnail, - intro_video_url, - is_active -) -VALUES ($1, $2, $3, $4, $5, COALESCE($6, true)) -RETURNING *; - - --- name: GetCourseByID :one -SELECT * -FROM courses -WHERE id = $1; - - --- name: GetCoursesByCategory :many -SELECT - COUNT(*) OVER () AS total_count, - id, - category_id, - title, - description, - thumbnail, - intro_video_url, - is_active -FROM courses -WHERE category_id = $1 -ORDER BY display_order ASC, id ASC -LIMIT sqlc.narg('limit')::INT -OFFSET sqlc.narg('offset')::INT; - --- name: GetAllCourses :many -SELECT - COUNT(*) OVER () AS total_count, - c.id, - c.category_id, - c.sub_category_id, - c.title, - c.description, - c.thumbnail, - c.intro_video_url, - c.is_active -FROM courses c -ORDER BY c.display_order ASC, c.id ASC -LIMIT sqlc.narg('limit')::INT -OFFSET sqlc.narg('offset')::INT; - --- name: GetHumanLanguageCourses :many -SELECT - COUNT(*) OVER () AS total_count, - c.id, - c.category_id, - c.sub_category_id, - c.title, - c.description, - c.thumbnail, - c.intro_video_url, - c.is_active -FROM courses c -JOIN course_categories cc ON cc.id = c.category_id -WHERE lower(trim(cc.name)) = 'human language' -ORDER BY c.display_order ASC, c.id ASC -LIMIT sqlc.narg('limit')::INT -OFFSET sqlc.narg('offset')::INT; - --- name: GetCoursesBySubCategory :many -SELECT - COUNT(*) OVER () AS total_count, - c.id, - c.category_id, - c.sub_category_id, - c.title, - c.description, - c.thumbnail, - c.intro_video_url, - c.is_active -FROM courses c -WHERE c.sub_category_id = $1 -ORDER BY c.display_order ASC, c.id ASC -LIMIT sqlc.narg('limit')::INT -OFFSET sqlc.narg('offset')::INT; - - --- name: UpdateCourse :exec -UPDATE courses -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - thumbnail = COALESCE($3, thumbnail), - intro_video_url = COALESCE($4, intro_video_url), - is_active = COALESCE($5, is_active) -WHERE id = $6; - - --- name: DeleteCourse :exec -DELETE FROM courses -WHERE id = $1; - --- name: ReorderCourses :exec -UPDATE courses -SET display_order = bulk.position -FROM ( - SELECT unnest(@ids::BIGINT[]) AS id, unnest(@positions::INT[]) AS position -) AS bulk -WHERE courses.id = bulk.id; diff --git a/db/query/hierarchy.sql b/db/query/hierarchy.sql deleted file mode 100644 index 2b84005..0000000 --- a/db/query/hierarchy.sql +++ /dev/null @@ -1,586 +0,0 @@ --- 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: GetAllLevels :many -SELECT - COUNT(*) OVER () AS total_count, - l.* -FROM levels l -ORDER BY l.display_order ASC, l.id ASC -LIMIT sqlc.narg('limit')::INT -OFFSET sqlc.narg('offset')::INT; - --- name: GetLevelByID :one -SELECT * -FROM levels -WHERE id = $1; - --- name: GetModulesByLevelID :many -SELECT * -FROM modules -WHERE level_id = $1 - AND is_active = TRUE -ORDER BY display_order ASC, id ASC; - --- name: GetAllModules :many -SELECT - COUNT(*) OVER () AS total_count, - m.* -FROM modules m -ORDER BY m.display_order ASC, m.id ASC -LIMIT sqlc.narg('limit')::INT -OFFSET sqlc.narg('offset')::INT; - --- name: GetModuleByID :one -SELECT * -FROM modules -WHERE id = $1; - --- name: GetSubModulesByModuleID :many -SELECT * -FROM sub_modules -WHERE module_id = $1 - AND is_active = TRUE -ORDER BY display_order ASC, id ASC; - --- name: GetAllSubModules :many -SELECT - COUNT(*) OVER () AS total_count, - sm.* -FROM sub_modules sm -ORDER BY sm.display_order ASC, sm.id ASC -LIMIT sqlc.narg('limit')::INT -OFFSET sqlc.narg('offset')::INT; - --- name: GetSubModuleByID :one -SELECT * -FROM sub_modules -WHERE id = $1; - --- 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 * -FROM sub_module_lessons -WHERE sub_module_id = $1 - AND is_active = TRUE -ORDER BY display_order ASC, id ASC; - --- name: GetSubModuleLessonsAll :many -SELECT * -FROM sub_module_lessons -WHERE sub_module_id = $1 -ORDER BY display_order ASC, id ASC; - --- name: GetSubModuleLessonByID :one -SELECT * -FROM sub_module_lessons -WHERE id = $1; - --- 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, - smp.inactive_since, - 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: GetSubModulePracticeByID :one -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, - smp.inactive_since, - 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.is_active = TRUE - AND (smp.id = $1 OR smp.question_set_id = $1) -ORDER BY (smp.id = $1) DESC -LIMIT 1; - --- name: GetSubModuleCapstones :many -SELECT - smc.id, - smc.sub_module_id, - smc.title, - smc.description, - smc.tips, - smc.thumbnail, - smc.question_set_id, - smc.display_order, - smc.is_active, - smc.inactive_since, - qs.status, - qs.set_type, - qs.time_limit_minutes, - qs.passing_score, - qs.shuffle_questions, - (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count -FROM sub_module_capstones smc -JOIN question_sets qs ON qs.id = smc.question_set_id -WHERE smc.sub_module_id = $1 - AND smc.is_active = TRUE - AND qs.set_type = 'CAPSTONE' -ORDER BY smc.display_order ASC, smc.id ASC; - --- name: GetSubModuleCapstoneByID :one -SELECT - smc.id, - smc.sub_module_id, - smc.title, - smc.description, - smc.tips, - smc.thumbnail, - smc.question_set_id, - smc.display_order, - smc.is_active, - smc.inactive_since, - qs.status, - qs.set_type, - qs.time_limit_minutes, - qs.passing_score, - qs.shuffle_questions, - (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count -FROM sub_module_capstones smc -JOIN question_sets qs ON qs.id = smc.question_set_id -WHERE smc.id = $1 - AND smc.is_active = TRUE - AND qs.set_type = 'CAPSTONE'; - --- name: GetFullHierarchyByCourseID :many -SELECT - c.id AS course_id, - c.title AS course_title, - l.id AS level_id, - l.cefr_level, - l.title AS level_title, - l.description AS level_description, - l.thumbnail AS level_thumbnail, - m.id AS module_id, - m.title AS module_title, - m.icon_url AS module_icon_url, - sm.id AS sub_module_id, - sm.title AS sub_module_title, - sm.description AS sub_module_description, - sm.thumbnail AS sub_module_thumbnail, - sm.tips AS sub_module_tips, - sm.display_order AS sub_module_display_order -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: GetCourseSubCategories :many -SELECT - COUNT(*) OVER () AS total_count, - csc.id, - csc.category_id, - cc.name AS category_name, - csc.name, - csc.description, - csc.display_order, - csc.is_active, - csc.created_at -FROM course_sub_categories csc -JOIN course_categories cc ON cc.id = csc.category_id -WHERE csc.is_active = TRUE -ORDER BY csc.display_order ASC, csc.id ASC -LIMIT sqlc.narg('limit')::INT -OFFSET sqlc.narg('offset')::INT; - --- name: GetCourseSubCategoriesByCategoryID :many -SELECT - COUNT(*) OVER () AS total_count, - csc.id, - csc.category_id, - cc.name AS category_name, - csc.name, - csc.description, - csc.display_order, - csc.is_active, - csc.created_at -FROM course_sub_categories csc -JOIN course_categories cc ON cc.id = csc.category_id -WHERE csc.category_id = $1 - AND csc.is_active = TRUE -ORDER BY csc.display_order ASC, csc.id ASC -LIMIT sqlc.narg('limit')::INT -OFFSET sqlc.narg('offset')::INT; - --- name: GetHumanLanguageCourseSubCategories :many -SELECT - COUNT(*) OVER () AS total_count, - csc.id, - csc.category_id, - cc.name AS category_name, - csc.name, - csc.description, - csc.display_order, - csc.is_active, - csc.created_at -FROM course_sub_categories csc -JOIN course_categories cc ON cc.id = csc.category_id -WHERE csc.is_active = TRUE - AND cc.is_active = TRUE - AND lower(trim(cc.name)) = 'human language' -ORDER BY csc.display_order ASC, csc.id ASC -LIMIT sqlc.narg('limit')::INT -OFFSET sqlc.narg('offset')::INT; - --- name: CreateLevel :one -INSERT INTO levels ( - course_id, - cefr_level, - title, - description, - thumbnail, - display_order, - is_active -) -VALUES ($1, $2, $3, $4, $5, COALESCE($6, 0), COALESCE($7, TRUE)) -RETURNING *; - --- name: UpdateLevel :one -UPDATE levels -SET - title = $1, - description = $2, - thumbnail = $3, - display_order = $4, - is_active = $5 -WHERE id = $6 -RETURNING *; - --- name: CreateModule :one -INSERT INTO modules ( - level_id, - title, - description, - icon_url, - display_order, - is_active -) -VALUES ($1, $2, $3, $4, COALESCE($5, 0), COALESCE($6, TRUE)) -RETURNING *; - --- name: UpdateModule :one -UPDATE modules -SET - title = $1, - description = $2, - icon_url = $3, - display_order = $4, - is_active = $5 -WHERE id = $6 -RETURNING *; - --- name: CreateSubModule :one -INSERT INTO sub_modules ( - module_id, - title, - description, - thumbnail, - tips, - display_order, - is_active -) -VALUES ($1, $2, $3, $4, $5, COALESCE($6, 0), COALESCE($7, TRUE)) -RETURNING *; - --- name: UpdateSubModule :one -UPDATE sub_modules -SET - title = $1, - description = $2, - thumbnail = $3, - tips = $4, - display_order = $5, - is_active = $6 -WHERE id = $7 -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: CreateSubModuleLesson :one -INSERT INTO sub_module_lessons ( - sub_module_id, - title, - description, - thumbnail, - teaching_text, - teaching_image_url, - teaching_audio_url, - teaching_video_url, - display_order, - is_active, - inactive_since -) -VALUES ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - COALESCE($9, 0), - COALESCE($10, TRUE), - CASE WHEN COALESCE($10, TRUE) THEN NULL ELSE NOW() END -) -RETURNING *; - --- name: UpdateSubModuleLesson :one -UPDATE sub_module_lessons -SET - sub_module_id = $1, - title = $2, - description = $3, - thumbnail = $4, - teaching_text = $5, - teaching_image_url = $6, - teaching_audio_url = $7, - teaching_video_url = $8, - display_order = $9, - is_active = $10, - inactive_since = CASE - WHEN $10 THEN NULL - WHEN is_active THEN NOW() - ELSE inactive_since - END -WHERE id = $11 -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, - inactive_since -) -VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE), CASE WHEN COALESCE($8, TRUE) THEN NULL ELSE NOW() END) -RETURNING *; - --- name: CreateSubModuleCapstone :one -INSERT INTO sub_module_capstones ( - sub_module_id, - title, - description, - tips, - thumbnail, - question_set_id, - display_order, - is_active, - inactive_since -) -VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE), CASE WHEN COALESCE($8, TRUE) THEN NULL ELSE NOW() END) -RETURNING *; - --- name: UpdateSubModuleCapstone :one -UPDATE sub_module_capstones -SET - title = $1, - description = $2, - tips = $3, - thumbnail = $4, - display_order = $5, - is_active = $6, - inactive_since = CASE - WHEN $6 THEN NULL - WHEN is_active THEN NOW() - ELSE inactive_since - END -WHERE id = $7 -RETURNING *; - --- name: PurgeInactiveSubModuleLessonsBefore :execrows -DELETE FROM sub_module_lessons -WHERE is_active = FALSE - AND inactive_since IS NOT NULL - AND inactive_since < $1; - --- name: PurgeInactiveSubModulePracticesBefore :execrows -DELETE FROM question_sets qs -USING ( - SELECT question_set_id - FROM sub_module_practices - WHERE is_active = FALSE - AND inactive_since IS NOT NULL - AND inactive_since < $1 -) doomed -WHERE qs.id = doomed.question_set_id; - --- name: PurgeInactiveSubModuleCapstonesBefore :execrows -DELETE FROM question_sets qs -USING ( - SELECT question_set_id - FROM sub_module_capstones - WHERE is_active = FALSE - AND inactive_since IS NOT NULL - AND inactive_since < $1 -) doomed -WHERE qs.id = doomed.question_set_id; - --- name: GetModuleCapstones :many -SELECT - mc.id, - mc.module_id, - mc.title, - mc.description, - mc.tips, - mc.thumbnail, - mc.question_set_id, - mc.display_order, - mc.is_active, - qs.status, - qs.set_type, - qs.time_limit_minutes, - qs.passing_score, - qs.shuffle_questions, - (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count -FROM module_capstones mc -JOIN question_sets qs ON qs.id = mc.question_set_id -WHERE mc.module_id = $1 - AND mc.is_active = TRUE - AND qs.set_type = 'CAPSTONE' -ORDER BY mc.display_order ASC, mc.id ASC; - --- name: GetModuleCapstoneByID :one -SELECT - mc.id, - mc.module_id, - mc.title, - mc.description, - mc.tips, - mc.thumbnail, - mc.question_set_id, - mc.display_order, - mc.is_active, - qs.status, - qs.set_type, - qs.time_limit_minutes, - qs.passing_score, - qs.shuffle_questions, - (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count -FROM module_capstones mc -JOIN question_sets qs ON qs.id = mc.question_set_id -WHERE mc.id = $1 - AND mc.is_active = TRUE - AND qs.set_type = 'CAPSTONE'; - --- name: CreateModuleCapstone :one -INSERT INTO module_capstones ( - module_id, - title, - description, - tips, - thumbnail, - question_set_id, - display_order, - is_active -) -VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE)) -RETURNING *; - --- name: UpdateModuleCapstone :one -UPDATE module_capstones -SET - title = $1, - description = $2, - tips = $3, - thumbnail = $4, - display_order = $5, - is_active = $6 -WHERE id = $7 -RETURNING *; - diff --git a/db/query/practice_progress.sql b/db/query/practice_progress.sql index 73cecf4..e9b353e 100644 --- a/db/query/practice_progress.sql +++ b/db/query/practice_progress.sql @@ -29,27 +29,16 @@ LIMIT 1; -- name: MarkPracticeCompleted :execrows INSERT INTO user_practice_progress ( user_id, - sub_course_id, question_set_id, completed_at, updated_at ) -SELECT +VALUES ( @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, + @question_set_id::BIGINT, 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/programs.sql b/db/query/programs.sql new file mode 100644 index 0000000..d8c1bb1 --- /dev/null +++ b/db/query/programs.sql @@ -0,0 +1,36 @@ +-- name: CreateProgram :one +INSERT INTO programs (name, description, thumbnail) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetProgramByID :one +SELECT * +FROM programs +WHERE id = $1; + +-- name: ListPrograms :many +SELECT + COUNT(*) OVER () AS total_count, + p.id, + p.name, + p.description, + p.thumbnail, + p.created_at, + p.updated_at +FROM programs p +ORDER BY p.created_at DESC +LIMIT $1 OFFSET $2; + +-- name: UpdateProgram :one +UPDATE programs +SET + name = COALESCE(sqlc.narg('name')::varchar, name), + description = COALESCE(sqlc.narg('description')::text, description), + thumbnail = COALESCE(sqlc.narg('thumbnail')::text, thumbnail), + updated_at = CURRENT_TIMESTAMP +WHERE id = sqlc.arg('id') +RETURNING *; + +-- name: DeleteProgram :exec +DELETE FROM programs +WHERE id = $1; diff --git a/db/query/question_sets.sql b/db/query/question_sets.sql index 5b9a704..0a2bec9 100644 --- a/db/query/question_sets.sql +++ b/db/query/question_sets.sql @@ -11,10 +11,9 @@ INSERT INTO question_sets ( passing_score, shuffle_questions, status, - sub_course_video_id, intro_video_url ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12, $13) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12) RETURNING *; -- name: GetQuestionSetByID :one @@ -61,9 +60,8 @@ SET shuffle_questions = COALESCE($7, shuffle_questions), status = COALESCE($8, status), intro_video_url = COALESCE($9, intro_video_url), - sub_course_video_id = COALESCE($10, sub_course_video_id), updated_at = CURRENT_TIMESTAMP -WHERE id = $11; +WHERE id = $10; -- name: ArchiveQuestionSet :exec UPDATE question_sets @@ -82,16 +80,6 @@ WHERE set_type = 'INITIAL_ASSESSMENT' ORDER BY created_at DESC LIMIT 1; --- name: GetSubCourseInitialAssessmentSet :one -SELECT * -FROM question_sets -WHERE set_type = 'INITIAL_ASSESSMENT' - AND owner_type = 'SUB_COURSE' - AND owner_id = $1 - AND status = 'PUBLISHED' -ORDER BY created_at DESC -LIMIT 1; - -- name: AddUserPersonaToQuestionSet :one INSERT INTO question_set_personas ( question_set_id, @@ -120,13 +108,6 @@ INNER JOIN question_set_personas qsp ON qsp.user_id = u.id WHERE qsp.question_set_id = $1 ORDER BY qsp.display_order ASC; --- name: UpdateQuestionSetVideoLink :exec -UPDATE question_sets -SET - sub_course_video_id = $1, - updated_at = CURRENT_TIMESTAMP -WHERE id = $2; - -- name: ReorderQuestionSets :exec UPDATE question_sets SET display_order = bulk.position diff --git a/docs/COURSE_MANAGEMENT_POSTMAN_COLLECTION.json b/docs/COURSE_MANAGEMENT_POSTMAN_COLLECTION.json deleted file mode 100644 index 7e3515f..0000000 --- a/docs/COURSE_MANAGEMENT_POSTMAN_COLLECTION.json +++ /dev/null @@ -1,825 +0,0 @@ -{ - "info": { - "_postman_id": "d8d17a29-5f9c-4f06-95fd-12e9862f97f8", - "name": "Yimaru Backend - Course Management APIs", - "description": "Fully documented Postman collection for all course-management related endpoints in Yimaru Backend.\n\nAuthentication:\n- All endpoints require `Authorization: Bearer {{accessToken}}`.\n\nBase URL:\n- `{{baseUrl}}/api/{{apiVersion}}`\n\nNotes:\n- IDs in path params must be positive integers.\n- Some update endpoints support partial updates.\n- Endpoint-level permission requirements are documented in each request description.", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "variable": [ - { - "key": "baseUrl", - "value": "https://api.yimaruacademy.com", - "type": "string" - }, - { - "key": "apiVersion", - "value": "v1", - "type": "string" - }, - { - "key": "accessToken", - "value": "", - "type": "string" - }, - { - "key": "categoryId", - "value": "1", - "type": "string" - }, - { - "key": "subCategoryId", - "value": "1", - "type": "string" - }, - { - "key": "courseId", - "value": "1", - "type": "string" - }, - { - "key": "levelId", - "value": "1", - "type": "string" - }, - { - "key": "moduleId", - "value": "1", - "type": "string" - }, - { - "key": "subModuleId", - "value": "1", - "type": "string" - }, - { - "key": "videoId", - "value": "1", - "type": "string" - }, - { - "key": "questionSetId", - "value": "1", - "type": "string" - }, - { - "key": "practiceId", - "value": "1", - "type": "string" - } - ], - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{accessToken}}", - "type": "string" - } - ] - }, - "item": [ - { - "name": "Categories & Courses", - "item": [ - { - "name": "List Course Categories", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/categories", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "categories" - ] - }, - "description": "Returns all course categories.\n\nPermission: `learning_tree.get`." - } - }, - { - "name": "Create Course Category", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Grammar\",\n \"is_active\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/categories", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "categories" - ] - }, - "description": "Creates a course category.\n\nRequired fields:\n- `name` (string)\n\nOptional fields:\n- `is_active` (boolean)\n\nPermission: `course_categories.create`." - } - }, - { - "name": "Delete Course Category", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/categories/{{categoryId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "categories", - "{{categoryId}}" - ] - }, - "description": "Deletes a category by ID.\n\nPath params:\n- `categoryId` (int, required)\n\nPermission: `course_categories.delete`." - } - }, - { - "name": "List Courses By Category", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/categories/{{categoryId}}/courses?offset=0&limit=50", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "categories", - "{{categoryId}}", - "courses" - ], - "query": [ - { - "key": "offset", - "value": "0" - }, - { - "key": "limit", - "value": "50" - } - ] - }, - "description": "Returns all courses under a category.\n\nPath params:\n- `categoryId` (int, required)\n\nQuery params:\n- `offset` (int, optional, default 0)\n- `limit` (int, optional, default 10000)\n\nPermission: `learning_tree.get`." - } - }, - { - "name": "Create Course", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"category_id\": 1,\n \"sub_category_id\": 1,\n \"title\": \"English Basics\",\n \"description\": \"Beginner-level course\",\n \"thumbnail\": \"https://cdn.example.com/course-thumb.jpg\",\n \"intro_video_url\": \"https://cdn.example.com/intro.mp4\",\n \"is_active\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "courses" - ] - }, - "description": "Creates a course.\n\nRequired fields:\n- `category_id` (int)\n- `title` (string)\n\nOptional fields:\n- `sub_category_id` (int)\n- `description` (string)\n- `thumbnail` (string URL)\n- `intro_video_url` (string URL)\n- `is_active` (boolean)\n\nPermission: `courses.create`." - } - }, - { - "name": "Update Course", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"title\": \"English Basics - Updated\",\n \"description\": \"Updated course description\",\n \"thumbnail\": \"https://cdn.example.com/new-thumb.jpg\",\n \"intro_video_url\": \"https://cdn.example.com/new-intro.mp4\",\n \"is_active\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "courses", - "{{courseId}}" - ] - }, - "description": "Updates an existing course (partial update accepted).\n\nPath params:\n- `courseId` (int, required)\n\nOptional fields:\n- `title`, `description`, `thumbnail`, `intro_video_url`, `is_active`\n\nPermission: `courses.update`." - } - }, - { - "name": "Delete Course", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "courses", - "{{courseId}}" - ] - }, - "description": "Deletes a course.\n\nPath params:\n- `courseId` (int, required)\n\nPermission: `courses.delete`." - } - }, - { - "name": "Update Course Thumbnail", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"thumbnail_url\": \"https://cdn.example.com/new-thumbnail.jpg\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}/thumbnail", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "courses", - "{{courseId}}", - "thumbnail" - ] - }, - "description": "Updates only the thumbnail of a course.\n\nPath params:\n- `courseId` (int, required)\n\nRequired fields:\n- `thumbnail_url` (string)\n\nPermission: `courses.upload_thumbnail`." - } - } - ] - }, - { - "name": "Hierarchy & Learning Path", - "item": [ - { - "name": "Get Unified Hierarchy", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/hierarchy", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "hierarchy" - ] - }, - "description": "Returns global hierarchy data.\n\nPermission: `learning_tree.get`." - } - }, - { - "name": "Get Human Language Hierarchy", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/human-language/hierarchy", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "human-language", - "hierarchy" - ] - }, - "description": "Alias endpoint for unified hierarchy under human-language path.\n\nPermission: `learning_tree.get`." - } - }, - { - "name": "Get Course Hierarchy", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}/hierarchy", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "courses", - "{{courseId}}", - "hierarchy" - ] - }, - "description": "Returns hierarchy nodes for one course.\n\nPath params:\n- `courseId` (int, required)\n\nPermission: `learning_tree.get`." - } - }, - { - "name": "Get Course Learning Path", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/courses/{{courseId}}/learning-path", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "courses", - "{{courseId}}", - "learning-path" - ] - }, - "description": "Returns learning-path projection for a course including sub-modules, videos, and practices.\n\nPath params:\n- `courseId` (int, required)\n\nPermission: `learning_tree.get`." - } - } - ] - }, - { - "name": "Sub-Categories, Levels, Modules, Sub-Modules", - "item": [ - { - "name": "Create Course Sub-Category", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"category_id\": 1,\n \"name\": \"Everyday Conversation\",\n \"description\": \"Spoken communication track\",\n \"display_order\": 1,\n \"is_active\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-categories", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "sub-categories" - ] - }, - "description": "Creates a sub-category under a course category.\n\nRequired fields:\n- `category_id` (int)\n- `name` (string)\n\nOptional fields:\n- `description` (string)\n- `display_order` (int)\n- `is_active` (boolean)\n\nPermission: `course_categories.create`." - } - }, - { - "name": "Delete Course Sub-Category", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-categories/{{subCategoryId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "sub-categories", - "{{subCategoryId}}" - ] - }, - "description": "Deletes a sub-category.\n\nPath params:\n- `subCategoryId` (int, required)\n\nPermission: `course_categories.delete`." - } - }, - { - "name": "Create Level", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"course_id\": 1,\n \"cefr_level\": \"A1\",\n \"display_order\": 1,\n \"is_active\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/levels", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "levels" - ] - }, - "description": "Creates a CEFR level under a course.\n\nRequired fields:\n- `course_id` (int)\n- `cefr_level` (one of: A1, A2, A3, B1, B2, B3, C1, C2, C3)\n\nOptional fields:\n- `display_order` (int)\n- `is_active` (boolean)\n\nPermission: `subcourses.create`." - } - }, - { - "name": "Create Module", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"level_id\": 1,\n \"title\": \"Module 1: Introductions\",\n \"description\": \"Core introduction module\",\n \"display_order\": 1,\n \"is_active\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/modules", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "modules" - ] - }, - "description": "Creates a module under a level.\n\nRequired fields:\n- `level_id` (int)\n- `title` (string)\n\nOptional fields:\n- `description`, `display_order`, `is_active`\n\nPermission: `subcourses.create`." - } - }, - { - "name": "Delete Module", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/modules/{{moduleId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "modules", - "{{moduleId}}" - ] - }, - "description": "Deletes a module.\n\nPath params:\n- `moduleId` (int, required)\n\nPermission: `subcourses.delete`." - } - }, - { - "name": "Create Sub-Module", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"module_id\": 1,\n \"title\": \"Sub-Module 1: Greetings\",\n \"description\": \"Greetings and polite expressions\",\n \"display_order\": 1,\n \"is_active\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-modules", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "sub-modules" - ] - }, - "description": "Creates a sub-module under a module.\n\nRequired fields:\n- `module_id` (int)\n- `title` (string)\n\nOptional fields:\n- `description`, `display_order`, `is_active`\n\nPermission: `subcourses.create`." - } - }, - { - "name": "Update Sub-Module", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"title\": \"Sub-Module 1: Greetings (Updated)\",\n \"description\": \"Updated sub-module description\",\n \"is_active\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-modules/{{subModuleId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "sub-modules", - "{{subModuleId}}" - ] - }, - "description": "Updates a sub-module.\n\nPath params:\n- `subModuleId` (int, required)\n\nOptional fields:\n- `title`, `description`, `is_active`\n\nPermission: `subcourses.update`." - } - }, - { - "name": "Delete Sub-Module", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-modules/{{subModuleId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "sub-modules", - "{{subModuleId}}" - ] - }, - "description": "Deletes a sub-module.\n\nPath params:\n- `subModuleId` (int, required)\n\nPermission: `subcourses.delete`." - } - } - ] - }, - { - "name": "Sub-Module Videos", - "item": [ - { - "name": "Get Sub-Module Videos", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-modules/{{subModuleId}}/videos", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "sub-modules", - "{{subModuleId}}", - "videos" - ] - }, - "description": "Lists videos for a given sub-module.\n\nPath params:\n- `subModuleId` (int, required)\n\nPermission: `videos.list`." - } - }, - { - "name": "Create Sub-Module Video", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"sub_module_id\": 1,\n \"title\": \"Greeting Expressions\",\n \"description\": \"Main lesson video\",\n \"video_url\": \"https://cdn.example.com/videos/greetings.mp4\",\n \"duration\": 480,\n \"resolution\": \"1080p\",\n \"visibility\": \"public\",\n \"instructor_id\": \"instructor-123\",\n \"thumbnail\": \"https://cdn.example.com/thumbs/greetings.jpg\",\n \"display_order\": 1,\n \"status\": \"published\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-videos", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "sub-module-videos" - ] - }, - "description": "Creates a video under a sub-module.\n\nRequired fields:\n- `sub_module_id` (int)\n- `title` (string)\n- `video_url` (string URL)\n\nOptional fields:\n- `description`, `duration`, `resolution`, `visibility`, `instructor_id`, `thumbnail`, `display_order`, `status`\n\nPermission: `videos.create`." - } - }, - { - "name": "Update Sub-Module Video", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"title\": \"Greeting Expressions - Updated\",\n \"description\": \"Updated video description\",\n \"video_url\": \"https://cdn.example.com/videos/greetings-v2.mp4\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-videos/{{videoId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "sub-module-videos", - "{{videoId}}" - ] - }, - "description": "Updates a sub-module video.\n\nPath params:\n- `videoId` (int, required)\n\nRequired fields:\n- `title` (string)\n- `video_url` (string URL)\n\nOptional fields:\n- `description`\n\nPermission: `videos.update`." - } - }, - { - "name": "Delete Sub-Module Video", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-videos/{{videoId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "sub-module-videos", - "{{videoId}}" - ] - }, - "description": "Deletes a sub-module video.\n\nPath params:\n- `videoId` (int, required)\n\nPermission: `videos.delete`." - } - } - ] - }, - { - "name": "Lessons & Practices", - "item": [ - { - "name": "Attach Sub-Module Lesson (Question Set)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"sub_module_id\": 1,\n \"question_set_id\": 1,\n \"intro_video_url\": \"https://cdn.example.com/intro-lesson.mp4\",\n \"display_order\": 1,\n \"is_active\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-lessons", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "sub-module-lessons" - ] - }, - "description": "Links a question set lesson to a sub-module.\n\nRequired fields:\n- `sub_module_id` (int)\n- `question_set_id` (int)\n\nOptional fields:\n- `intro_video_url`, `display_order`, `is_active`\n\nPermission: `question_sets.update`." - } - }, - { - "name": "Create Sub-Module Practice", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"sub_module_id\": 1,\n \"title\": \"Practice: Basic Greetings\",\n \"description\": \"Practice set for greetings\",\n \"thumbnail\": \"https://cdn.example.com/practice-thumb.jpg\",\n \"intro_video_url\": \"https://cdn.example.com/practice-intro.mp4\",\n \"question_set_id\": 1,\n \"display_order\": 1,\n \"is_active\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/sub-module-practices", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "sub-module-practices" - ] - }, - "description": "Creates a practice under a sub-module.\n\nRequired fields:\n- `sub_module_id` (int)\n- `title` (string)\n- `question_set_id` (int)\n\nOptional fields:\n- `description`, `thumbnail`, `intro_video_url`, `display_order`, `is_active`\n\nPermission: `question_sets.update`." - } - }, - { - "name": "Update Practice", - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"title\": \"Practice: Basic Greetings - Updated\",\n \"description\": \"Updated practice details\",\n \"persona\": \"student\",\n \"is_active\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/practices/{{practiceId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "practices", - "{{practiceId}}" - ] - }, - "description": "Updates a practice.\n\nPath params:\n- `practiceId` (int, required)\n\nBehavior:\n- If `is_active` is provided, this endpoint updates practice status.\n- Otherwise, `title` is required and metadata update is applied.\n\nPermission: `question_sets.update`." - } - }, - { - "name": "Delete Practice", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/{{apiVersion}}/course-management/practices/{{practiceId}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "{{apiVersion}}", - "course-management", - "practices", - "{{practiceId}}" - ] - }, - "description": "Deletes a practice.\n\nPath params:\n- `practiceId` (int, required)\n\nPermission: `question_sets.delete`." - } - } - ] - } - ] -} diff --git a/gen/db/analytics.sql.go b/gen/db/analytics.sql.go index 1cea945..8dd0713 100644 --- a/gen/db/analytics.sql.go +++ b/gen/db/analytics.sql.go @@ -12,10 +12,10 @@ import ( const AnalyticsCourseCounts = `-- name: AnalyticsCourseCounts :one SELECT - (SELECT COUNT(*)::bigint FROM course_categories) AS total_categories, - (SELECT COUNT(*)::bigint FROM courses) AS total_courses, - (SELECT COUNT(*)::bigint FROM sub_courses) AS total_sub_courses, - (SELECT COUNT(*)::bigint FROM sub_course_videos) AS total_videos + 0::bigint AS total_categories, + 0::bigint AS total_courses, + 0::bigint AS total_sub_courses, + 0::bigint AS total_videos ` type AnalyticsCourseCountsRow struct { diff --git a/gen/db/compat_course_management.go b/gen/db/compat_course_management.go deleted file mode 100644 index 0a029f3..0000000 --- a/gen/db/compat_course_management.go +++ /dev/null @@ -1,227 +0,0 @@ -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -func (q *Queries) GetSubModuleByIDCompat(ctx context.Context, id int64) (SubModule, error) { - row := q.db.QueryRow(ctx, ` -SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id, thumbnail, tips -FROM sub_modules -WHERE id = $1 -`, id) - var i SubModule - err := row.Scan( - &i.ID, - &i.ModuleID, - &i.Title, - &i.Description, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.LegacySubCourseID, - &i.Thumbnail, - &i.Tips, - ) - return i, err -} - -func (q *Queries) UpdateSubModuleCompat(ctx context.Context, id int64, title string, description string, thumbnail string, tips string, displayOrder int32, isActive bool) error { - _, err := q.db.Exec(ctx, ` -UPDATE sub_modules -SET - title = $1, - description = NULLIF($2, ''), - thumbnail = NULLIF($3, ''), - tips = NULLIF($4, ''), - display_order = $5, - is_active = $6 -WHERE id = $7 -`, title, description, thumbnail, tips, displayOrder, isActive, id) - return err -} - -func (q *Queries) DeleteSubModuleCompat(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, `DELETE FROM sub_modules WHERE id = $1`, id) - return err -} - -func (q *Queries) DeleteModuleCompat(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, `DELETE FROM modules WHERE id = $1`, id) - return err -} - -func (q *Queries) UpdateSubModuleVideoCompat(ctx context.Context, id int64, title string, description string, videoURL string) error { - _, err := q.db.Exec(ctx, ` -UPDATE sub_module_videos -SET - title = $1, - description = NULLIF($2, ''), - video_url = $3 -WHERE id = $4 -`, title, description, videoURL, id) - return err -} - -func (q *Queries) DeleteSubModuleVideoCompat(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, `DELETE FROM sub_module_videos WHERE id = $1`, id) - return err -} - -func (q *Queries) UpdatePracticeCompat(ctx context.Context, id int64, title string, description string, persona string) error { - _, err := q.db.Exec(ctx, ` -UPDATE question_sets -SET - title = $1, - description = NULLIF($2, ''), - persona = NULLIF($3, ''), - updated_at = CURRENT_TIMESTAMP -WHERE id = $4 -`, title, description, persona, id) - if err != nil { - return err - } - - _, err = q.db.Exec(ctx, ` -UPDATE sub_module_practices -SET - title = $1, - description = NULLIF($2, '') -WHERE question_set_id = $3 -`, title, description, id) - return err -} - -func (q *Queries) UpdatePracticeStatusCompat(ctx context.Context, id int64, isActive bool) error { - status := "ARCHIVED" - if isActive { - status = "PUBLISHED" - } - - _, err := q.db.Exec(ctx, ` -UPDATE question_sets -SET - status = $1, - updated_at = CURRENT_TIMESTAMP -WHERE id = $2 -`, status, id) - if err != nil { - return err - } - - _, err = q.db.Exec(ctx, ` -UPDATE sub_module_practices -SET - is_active = $1, - inactive_since = CASE - WHEN $1 THEN NULL - WHEN is_active THEN NOW() - ELSE inactive_since - END -WHERE question_set_id = $2 -`, isActive, id) - return err -} - -func (q *Queries) DeletePracticeCompat(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, `DELETE FROM question_sets WHERE id = $1`, id) - return err -} - -// DeleteCapstoneCompat removes the backing question set (and cascades sub_module_capstones). -func (q *Queries) DeleteCapstoneCompat(ctx context.Context, capstoneID int64) error { - _, err := q.db.Exec(ctx, ` -DELETE FROM question_sets -WHERE id = (SELECT question_set_id FROM sub_module_capstones WHERE id = $1) -`, capstoneID) - return err -} - -// DeleteModuleCapstoneCompat removes the backing question set (and cascades module_capstones). -func (q *Queries) DeleteModuleCapstoneCompat(ctx context.Context, capstoneID int64) error { - _, err := q.db.Exec(ctx, ` -DELETE FROM question_sets -WHERE id = (SELECT question_set_id FROM module_capstones WHERE id = $1) -`, capstoneID) - return err -} - -func (q *Queries) CreateCourseCompat( - ctx context.Context, - categoryID int64, - subCategoryID *int64, - title string, - description string, - thumbnail string, - introVideoURL string, - isActive bool, -) (Course, error) { - row := q.db.QueryRow(ctx, ` -INSERT INTO courses ( - category_id, - sub_category_id, - title, - description, - thumbnail, - intro_video_url, - is_active -) -VALUES ( - $1, - $2, - $3, - NULLIF($4, ''), - NULLIF($5, ''), - NULLIF($6, ''), - $7 -) -RETURNING id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id -`, categoryID, subCategoryID, title, description, thumbnail, introVideoURL, isActive) - - var i Course - err := row.Scan( - &i.ID, - &i.CategoryID, - &i.Title, - &i.Description, - &i.IsActive, - &i.Thumbnail, - &i.IntroVideoUrl, - &i.DisplayOrder, - &i.SubCategoryID, - ) - if err != nil { - return Course{}, err - } - if !i.SubCategoryID.Valid { - i.SubCategoryID = pgtype.Int8{Valid: false} - } - return i, nil -} - -func (q *Queries) DeleteCourseSubCategoryCompat(ctx context.Context, subCategoryID int64) error { - _, err := q.db.Exec(ctx, `DELETE FROM courses WHERE sub_category_id = $1`, subCategoryID) - if err != nil { - return err - } - - _, err = q.db.Exec(ctx, `DELETE FROM course_sub_categories WHERE id = $1`, subCategoryID) - return err -} - -func (q *Queries) DeleteCourseCategoryCompat(ctx context.Context, categoryID int64) error { - _, err := q.db.Exec(ctx, `DELETE FROM courses WHERE category_id = $1`, categoryID) - if err != nil { - return err - } - - _, err = q.db.Exec(ctx, `DELETE FROM course_sub_categories WHERE category_id = $1`, categoryID) - if err != nil { - return err - } - - _, err = q.db.Exec(ctx, `DELETE FROM course_categories WHERE id = $1`, categoryID) - return err -} diff --git a/gen/db/course_catagories.sql.go b/gen/db/course_catagories.sql.go deleted file mode 100644 index e2467b7..0000000 --- a/gen/db/course_catagories.sql.go +++ /dev/null @@ -1,158 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: course_catagories.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const CreateCourseCategory = `-- name: CreateCourseCategory :one -INSERT INTO course_categories ( - name, - is_active -) -VALUES ($1, COALESCE($2, true)) -RETURNING id, name, is_active, created_at, display_order -` - -type CreateCourseCategoryParams struct { - Name string `json:"name"` - Column2 interface{} `json:"column_2"` -} - -func (q *Queries) CreateCourseCategory(ctx context.Context, arg CreateCourseCategoryParams) (CourseCategory, error) { - row := q.db.QueryRow(ctx, CreateCourseCategory, arg.Name, arg.Column2) - var i CourseCategory - err := row.Scan( - &i.ID, - &i.Name, - &i.IsActive, - &i.CreatedAt, - &i.DisplayOrder, - ) - return i, err -} - -const DeleteCourseCategory = `-- name: DeleteCourseCategory :exec -DELETE FROM course_categories -WHERE id = $1 -` - -func (q *Queries) DeleteCourseCategory(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, DeleteCourseCategory, id) - return err -} - -const GetAllCourseCategories = `-- name: GetAllCourseCategories :many -SELECT - COUNT(*) OVER () AS total_count, - id, - name, - is_active, - created_at -FROM course_categories -ORDER BY display_order ASC, created_at DESC -LIMIT $2::INT -OFFSET $1::INT -` - -type GetAllCourseCategoriesParams struct { - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` -} - -type GetAllCourseCategoriesRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - Name string `json:"name"` - IsActive bool `json:"is_active"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -func (q *Queries) GetAllCourseCategories(ctx context.Context, arg GetAllCourseCategoriesParams) ([]GetAllCourseCategoriesRow, error) { - rows, err := q.db.Query(ctx, GetAllCourseCategories, arg.Offset, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAllCourseCategoriesRow - for rows.Next() { - var i GetAllCourseCategoriesRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.Name, - &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 GetCourseCategoryByID = `-- name: GetCourseCategoryByID :one -SELECT id, name, is_active, created_at, display_order -FROM course_categories -WHERE id = $1 -` - -func (q *Queries) GetCourseCategoryByID(ctx context.Context, id int64) (CourseCategory, error) { - row := q.db.QueryRow(ctx, GetCourseCategoryByID, id) - var i CourseCategory - err := row.Scan( - &i.ID, - &i.Name, - &i.IsActive, - &i.CreatedAt, - &i.DisplayOrder, - ) - return i, err -} - -const ReorderCourseCategories = `-- name: ReorderCourseCategories :exec -UPDATE course_categories -SET display_order = bulk.position -FROM ( - SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position -) AS bulk -WHERE course_categories.id = bulk.id -` - -type ReorderCourseCategoriesParams struct { - Ids []int64 `json:"ids"` - Positions []int32 `json:"positions"` -} - -func (q *Queries) ReorderCourseCategories(ctx context.Context, arg ReorderCourseCategoriesParams) error { - _, err := q.db.Exec(ctx, ReorderCourseCategories, arg.Ids, arg.Positions) - return err -} - -const UpdateCourseCategory = `-- name: UpdateCourseCategory :exec -UPDATE course_categories -SET - name = COALESCE($1, name), - is_active = COALESCE($2, is_active) -WHERE id = $3 -` - -type UpdateCourseCategoryParams struct { - Name string `json:"name"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateCourseCategory(ctx context.Context, arg UpdateCourseCategoryParams) error { - _, err := q.db.Exec(ctx, UpdateCourseCategory, arg.Name, arg.IsActive, arg.ID) - return err -} diff --git a/gen/db/courses.sql.go b/gen/db/courses.sql.go deleted file mode 100644 index 58b72f4..0000000 --- a/gen/db/courses.sql.go +++ /dev/null @@ -1,401 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: courses.sql - -package dbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const CreateCourse = `-- name: CreateCourse :one -INSERT INTO courses ( - category_id, - title, - description, - thumbnail, - intro_video_url, - 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, sub_category_id -` - -type CreateCourseParams struct { - CategoryID int64 `json:"category_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - IntroVideoUrl pgtype.Text `json:"intro_video_url"` - Column6 interface{} `json:"column_6"` -} - -func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Course, error) { - row := q.db.QueryRow(ctx, CreateCourse, - arg.CategoryID, - arg.Title, - arg.Description, - arg.Thumbnail, - arg.IntroVideoUrl, - arg.Column6, - ) - var i Course - err := row.Scan( - &i.ID, - &i.CategoryID, - &i.Title, - &i.Description, - &i.IsActive, - &i.Thumbnail, - &i.IntroVideoUrl, - &i.DisplayOrder, - &i.SubCategoryID, - ) - return i, err -} - -const DeleteCourse = `-- name: DeleteCourse :exec -DELETE FROM courses -WHERE id = $1 -` - -func (q *Queries) DeleteCourse(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, DeleteCourse, id) - return err -} - -const GetAllCourses = `-- name: GetAllCourses :many -SELECT - COUNT(*) OVER () AS total_count, - c.id, - c.category_id, - c.sub_category_id, - c.title, - c.description, - c.thumbnail, - c.intro_video_url, - c.is_active -FROM courses c -ORDER BY c.display_order ASC, c.id ASC -LIMIT $2::INT -OFFSET $1::INT -` - -type GetAllCoursesParams struct { - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` -} - -type GetAllCoursesRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - SubCategoryID pgtype.Int8 `json:"sub_category_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - IntroVideoUrl pgtype.Text `json:"intro_video_url"` - IsActive bool `json:"is_active"` -} - -func (q *Queries) GetAllCourses(ctx context.Context, arg GetAllCoursesParams) ([]GetAllCoursesRow, error) { - rows, err := q.db.Query(ctx, GetAllCourses, arg.Offset, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAllCoursesRow - for rows.Next() { - var i GetAllCoursesRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.CategoryID, - &i.SubCategoryID, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.IntroVideoUrl, - &i.IsActive, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetCourseByID = `-- name: GetCourseByID :one -SELECT id, category_id, title, description, is_active, thumbnail, intro_video_url, display_order, sub_category_id -FROM courses -WHERE id = $1 -` - -func (q *Queries) GetCourseByID(ctx context.Context, id int64) (Course, error) { - row := q.db.QueryRow(ctx, GetCourseByID, id) - var i Course - err := row.Scan( - &i.ID, - &i.CategoryID, - &i.Title, - &i.Description, - &i.IsActive, - &i.Thumbnail, - &i.IntroVideoUrl, - &i.DisplayOrder, - &i.SubCategoryID, - ) - return i, err -} - -const GetCoursesByCategory = `-- name: GetCoursesByCategory :many -SELECT - COUNT(*) OVER () AS total_count, - id, - category_id, - title, - description, - thumbnail, - intro_video_url, - is_active -FROM courses -WHERE category_id = $1 -ORDER BY display_order ASC, id ASC -LIMIT $3::INT -OFFSET $2::INT -` - -type GetCoursesByCategoryParams struct { - CategoryID int64 `json:"category_id"` - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` -} - -type GetCoursesByCategoryRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - IntroVideoUrl pgtype.Text `json:"intro_video_url"` - IsActive bool `json:"is_active"` -} - -func (q *Queries) GetCoursesByCategory(ctx context.Context, arg GetCoursesByCategoryParams) ([]GetCoursesByCategoryRow, error) { - rows, err := q.db.Query(ctx, GetCoursesByCategory, arg.CategoryID, arg.Offset, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetCoursesByCategoryRow - for rows.Next() { - var i GetCoursesByCategoryRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.CategoryID, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.IntroVideoUrl, - &i.IsActive, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetCoursesBySubCategory = `-- name: GetCoursesBySubCategory :many -SELECT - COUNT(*) OVER () AS total_count, - c.id, - c.category_id, - c.sub_category_id, - c.title, - c.description, - c.thumbnail, - c.intro_video_url, - c.is_active -FROM courses c -WHERE c.sub_category_id = $1 -ORDER BY c.display_order ASC, c.id ASC -LIMIT $3::INT -OFFSET $2::INT -` - -type GetCoursesBySubCategoryParams struct { - SubCategoryID pgtype.Int8 `json:"sub_category_id"` - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` -} - -type GetCoursesBySubCategoryRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - SubCategoryID pgtype.Int8 `json:"sub_category_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - IntroVideoUrl pgtype.Text `json:"intro_video_url"` - IsActive bool `json:"is_active"` -} - -func (q *Queries) GetCoursesBySubCategory(ctx context.Context, arg GetCoursesBySubCategoryParams) ([]GetCoursesBySubCategoryRow, error) { - rows, err := q.db.Query(ctx, GetCoursesBySubCategory, arg.SubCategoryID, arg.Offset, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetCoursesBySubCategoryRow - for rows.Next() { - var i GetCoursesBySubCategoryRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.CategoryID, - &i.SubCategoryID, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.IntroVideoUrl, - &i.IsActive, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetHumanLanguageCourses = `-- name: GetHumanLanguageCourses :many -SELECT - COUNT(*) OVER () AS total_count, - c.id, - c.category_id, - c.sub_category_id, - c.title, - c.description, - c.thumbnail, - c.intro_video_url, - c.is_active -FROM courses c -JOIN course_categories cc ON cc.id = c.category_id -WHERE lower(trim(cc.name)) = 'human language' -ORDER BY c.display_order ASC, c.id ASC -LIMIT $2::INT -OFFSET $1::INT -` - -type GetHumanLanguageCoursesParams struct { - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` -} - -type GetHumanLanguageCoursesRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - SubCategoryID pgtype.Int8 `json:"sub_category_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - IntroVideoUrl pgtype.Text `json:"intro_video_url"` - IsActive bool `json:"is_active"` -} - -func (q *Queries) GetHumanLanguageCourses(ctx context.Context, arg GetHumanLanguageCoursesParams) ([]GetHumanLanguageCoursesRow, error) { - rows, err := q.db.Query(ctx, GetHumanLanguageCourses, arg.Offset, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetHumanLanguageCoursesRow - for rows.Next() { - var i GetHumanLanguageCoursesRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.CategoryID, - &i.SubCategoryID, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.IntroVideoUrl, - &i.IsActive, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const ReorderCourses = `-- name: ReorderCourses :exec -UPDATE courses -SET display_order = bulk.position -FROM ( - SELECT unnest($1::BIGINT[]) AS id, unnest($2::INT[]) AS position -) AS bulk -WHERE courses.id = bulk.id -` - -type ReorderCoursesParams struct { - Ids []int64 `json:"ids"` - Positions []int32 `json:"positions"` -} - -func (q *Queries) ReorderCourses(ctx context.Context, arg ReorderCoursesParams) error { - _, err := q.db.Exec(ctx, ReorderCourses, arg.Ids, arg.Positions) - return err -} - -const UpdateCourse = `-- name: UpdateCourse :exec -UPDATE courses -SET - title = COALESCE($1, title), - description = COALESCE($2, description), - thumbnail = COALESCE($3, thumbnail), - intro_video_url = COALESCE($4, intro_video_url), - is_active = COALESCE($5, is_active) -WHERE id = $6 -` - -type UpdateCourseParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - IntroVideoUrl pgtype.Text `json:"intro_video_url"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateCourse(ctx context.Context, arg UpdateCourseParams) error { - _, err := q.db.Exec(ctx, UpdateCourse, - arg.Title, - arg.Description, - arg.Thumbnail, - arg.IntroVideoUrl, - arg.IsActive, - arg.ID, - ) - return err -} diff --git a/gen/db/hierarchy.sql.go b/gen/db/hierarchy.sql.go deleted file mode 100644 index 07d955f..0000000 --- a/gen/db/hierarchy.sql.go +++ /dev/null @@ -1,2179 +0,0 @@ -// 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 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, - title, - description, - thumbnail, - display_order, - is_active -) -VALUES ($1, $2, $3, $4, $5, COALESCE($6, 0), COALESCE($7, TRUE)) -RETURNING id, course_id, cefr_level, display_order, is_active, created_at, title, description, thumbnail -` - -type CreateLevelParams struct { - CourseID int64 `json:"course_id"` - CefrLevel string `json:"cefr_level"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - Column6 interface{} `json:"column_6"` - Column7 interface{} `json:"column_7"` -} - -func (q *Queries) CreateLevel(ctx context.Context, arg CreateLevelParams) (Level, error) { - row := q.db.QueryRow(ctx, CreateLevel, - arg.CourseID, - arg.CefrLevel, - arg.Title, - arg.Description, - arg.Thumbnail, - arg.Column6, - arg.Column7, - ) - var i Level - err := row.Scan( - &i.ID, - &i.CourseID, - &i.CefrLevel, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.Title, - &i.Description, - &i.Thumbnail, - ) - return i, err -} - -const CreateModule = `-- name: CreateModule :one -INSERT INTO modules ( - level_id, - title, - description, - icon_url, - display_order, - is_active -) -VALUES ($1, $2, $3, $4, COALESCE($5, 0), COALESCE($6, TRUE)) -RETURNING id, level_id, title, description, display_order, is_active, created_at, icon_url -` - -type CreateModuleParams struct { - LevelID int64 `json:"level_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - IconUrl pgtype.Text `json:"icon_url"` - Column5 interface{} `json:"column_5"` - Column6 interface{} `json:"column_6"` -} - -func (q *Queries) CreateModule(ctx context.Context, arg CreateModuleParams) (Module, error) { - row := q.db.QueryRow(ctx, CreateModule, - arg.LevelID, - arg.Title, - arg.Description, - arg.IconUrl, - arg.Column5, - arg.Column6, - ) - var i Module - err := row.Scan( - &i.ID, - &i.LevelID, - &i.Title, - &i.Description, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.IconUrl, - ) - return i, err -} - -const CreateModuleCapstone = `-- name: CreateModuleCapstone :one -INSERT INTO module_capstones ( - module_id, - title, - description, - tips, - thumbnail, - question_set_id, - display_order, - is_active -) -VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE)) -RETURNING id, module_id, title, description, tips, thumbnail, question_set_id, display_order, is_active, created_at -` - -type CreateModuleCapstoneParams struct { - ModuleID int64 `json:"module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Tips pgtype.Text `json:"tips"` - Thumbnail pgtype.Text `json:"thumbnail"` - QuestionSetID int64 `json:"question_set_id"` - Column7 interface{} `json:"column_7"` - Column8 interface{} `json:"column_8"` -} - -func (q *Queries) CreateModuleCapstone(ctx context.Context, arg CreateModuleCapstoneParams) (ModuleCapstone, error) { - row := q.db.QueryRow(ctx, CreateModuleCapstone, - arg.ModuleID, - arg.Title, - arg.Description, - arg.Tips, - arg.Thumbnail, - arg.QuestionSetID, - arg.Column7, - arg.Column8, - ) - var i ModuleCapstone - err := row.Scan( - &i.ID, - &i.ModuleID, - &i.Title, - &i.Description, - &i.Tips, - &i.Thumbnail, - &i.QuestionSetID, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - ) - return i, err -} - -const CreateSubModule = `-- name: CreateSubModule :one -INSERT INTO sub_modules ( - module_id, - title, - description, - thumbnail, - tips, - display_order, - is_active -) -VALUES ($1, $2, $3, $4, $5, COALESCE($6, 0), COALESCE($7, TRUE)) -RETURNING id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id, thumbnail, tips -` - -type CreateSubModuleParams struct { - ModuleID int64 `json:"module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - Tips pgtype.Text `json:"tips"` - Column6 interface{} `json:"column_6"` - Column7 interface{} `json:"column_7"` -} - -func (q *Queries) CreateSubModule(ctx context.Context, arg CreateSubModuleParams) (SubModule, error) { - row := q.db.QueryRow(ctx, CreateSubModule, - arg.ModuleID, - arg.Title, - arg.Description, - arg.Thumbnail, - arg.Tips, - arg.Column6, - arg.Column7, - ) - var i SubModule - err := row.Scan( - &i.ID, - &i.ModuleID, - &i.Title, - &i.Description, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.LegacySubCourseID, - &i.Thumbnail, - &i.Tips, - ) - return i, err -} - -const CreateSubModuleCapstone = `-- name: CreateSubModuleCapstone :one -INSERT INTO sub_module_capstones ( - sub_module_id, - title, - description, - tips, - thumbnail, - question_set_id, - display_order, - is_active, - inactive_since -) -VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE), CASE WHEN COALESCE($8, TRUE) THEN NULL ELSE NOW() END) -RETURNING id, sub_module_id, title, description, tips, thumbnail, question_set_id, display_order, is_active, created_at, inactive_since -` - -type CreateSubModuleCapstoneParams struct { - SubModuleID int64 `json:"sub_module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Tips pgtype.Text `json:"tips"` - Thumbnail pgtype.Text `json:"thumbnail"` - QuestionSetID int64 `json:"question_set_id"` - Column7 interface{} `json:"column_7"` - Column8 interface{} `json:"column_8"` -} - -func (q *Queries) CreateSubModuleCapstone(ctx context.Context, arg CreateSubModuleCapstoneParams) (SubModuleCapstone, error) { - row := q.db.QueryRow(ctx, CreateSubModuleCapstone, - arg.SubModuleID, - arg.Title, - arg.Description, - arg.Tips, - arg.Thumbnail, - arg.QuestionSetID, - arg.Column7, - arg.Column8, - ) - var i SubModuleCapstone - err := row.Scan( - &i.ID, - &i.SubModuleID, - &i.Title, - &i.Description, - &i.Tips, - &i.Thumbnail, - &i.QuestionSetID, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.InactiveSince, - ) - return i, err -} - -const CreateSubModuleLesson = `-- name: CreateSubModuleLesson :one -INSERT INTO sub_module_lessons ( - sub_module_id, - title, - description, - thumbnail, - teaching_text, - teaching_image_url, - teaching_audio_url, - teaching_video_url, - display_order, - is_active, - inactive_since -) -VALUES ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - COALESCE($9, 0), - COALESCE($10, TRUE), - CASE WHEN COALESCE($10, TRUE) THEN NULL ELSE NOW() END -) -RETURNING id, sub_module_id, display_order, is_active, created_at, title, description, thumbnail, teaching_text, teaching_image_url, teaching_audio_url, teaching_video_url, inactive_since -` - -type CreateSubModuleLessonParams struct { - SubModuleID int64 `json:"sub_module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - TeachingText pgtype.Text `json:"teaching_text"` - TeachingImageUrl pgtype.Text `json:"teaching_image_url"` - TeachingAudioUrl pgtype.Text `json:"teaching_audio_url"` - TeachingVideoUrl pgtype.Text `json:"teaching_video_url"` - Column9 interface{} `json:"column_9"` - Column10 interface{} `json:"column_10"` -} - -func (q *Queries) CreateSubModuleLesson(ctx context.Context, arg CreateSubModuleLessonParams) (SubModuleLesson, error) { - row := q.db.QueryRow(ctx, CreateSubModuleLesson, - arg.SubModuleID, - arg.Title, - arg.Description, - arg.Thumbnail, - arg.TeachingText, - arg.TeachingImageUrl, - arg.TeachingAudioUrl, - arg.TeachingVideoUrl, - arg.Column9, - arg.Column10, - ) - var i SubModuleLesson - err := row.Scan( - &i.ID, - &i.SubModuleID, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.TeachingText, - &i.TeachingImageUrl, - &i.TeachingAudioUrl, - &i.TeachingVideoUrl, - &i.InactiveSince, - ) - 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, - inactive_since -) -VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 0), COALESCE($8, TRUE), CASE WHEN COALESCE($8, TRUE) THEN NULL ELSE NOW() END) -RETURNING id, sub_module_id, question_set_id, intro_video_url, display_order, is_active, created_at, title, description, thumbnail, inactive_since -` - -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.QuestionSetID, - &i.IntroVideoUrl, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.InactiveSince, - ) - 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 GetAllLevels = `-- name: GetAllLevels :many -SELECT - COUNT(*) OVER () AS total_count, - l.id, l.course_id, l.cefr_level, l.display_order, l.is_active, l.created_at, l.title, l.description, l.thumbnail -FROM levels l -ORDER BY l.display_order ASC, l.id ASC -LIMIT $2::INT -OFFSET $1::INT -` - -type GetAllLevelsParams struct { - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` -} - -type GetAllLevelsRow struct { - TotalCount int64 `json:"total_count"` - 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"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` -} - -func (q *Queries) GetAllLevels(ctx context.Context, arg GetAllLevelsParams) ([]GetAllLevelsRow, error) { - rows, err := q.db.Query(ctx, GetAllLevels, arg.Offset, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAllLevelsRow - for rows.Next() { - var i GetAllLevelsRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.CourseID, - &i.CefrLevel, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.Title, - &i.Description, - &i.Thumbnail, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetAllModules = `-- name: GetAllModules :many -SELECT - COUNT(*) OVER () AS total_count, - m.id, m.level_id, m.title, m.description, m.display_order, m.is_active, m.created_at, m.icon_url -FROM modules m -ORDER BY m.display_order ASC, m.id ASC -LIMIT $2::INT -OFFSET $1::INT -` - -type GetAllModulesParams struct { - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` -} - -type GetAllModulesRow struct { - TotalCount int64 `json:"total_count"` - 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"` - IconUrl pgtype.Text `json:"icon_url"` -} - -func (q *Queries) GetAllModules(ctx context.Context, arg GetAllModulesParams) ([]GetAllModulesRow, error) { - rows, err := q.db.Query(ctx, GetAllModules, arg.Offset, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAllModulesRow - for rows.Next() { - var i GetAllModulesRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.LevelID, - &i.Title, - &i.Description, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.IconUrl, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetAllSubModules = `-- name: GetAllSubModules :many -SELECT - COUNT(*) OVER () AS total_count, - sm.id, sm.module_id, sm.title, sm.description, sm.display_order, sm.is_active, sm.created_at, sm.legacy_sub_course_id, sm.thumbnail, sm.tips -FROM sub_modules sm -ORDER BY sm.display_order ASC, sm.id ASC -LIMIT $2::INT -OFFSET $1::INT -` - -type GetAllSubModulesParams struct { - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` -} - -type GetAllSubModulesRow struct { - TotalCount int64 `json:"total_count"` - 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"` - Thumbnail pgtype.Text `json:"thumbnail"` - Tips pgtype.Text `json:"tips"` -} - -func (q *Queries) GetAllSubModules(ctx context.Context, arg GetAllSubModulesParams) ([]GetAllSubModulesRow, error) { - rows, err := q.db.Query(ctx, GetAllSubModules, arg.Offset, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAllSubModulesRow - for rows.Next() { - var i GetAllSubModulesRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.ModuleID, - &i.Title, - &i.Description, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.LegacySubCourseID, - &i.Thumbnail, - &i.Tips, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetCourseSubCategories = `-- name: GetCourseSubCategories :many -SELECT - COUNT(*) OVER () AS total_count, - csc.id, - csc.category_id, - cc.name AS category_name, - csc.name, - csc.description, - csc.display_order, - csc.is_active, - csc.created_at -FROM course_sub_categories csc -JOIN course_categories cc ON cc.id = csc.category_id -WHERE csc.is_active = TRUE -ORDER BY csc.display_order ASC, csc.id ASC -LIMIT $2::INT -OFFSET $1::INT -` - -type GetCourseSubCategoriesParams struct { - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` -} - -type GetCourseSubCategoriesRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - CategoryName string `json:"category_name"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -func (q *Queries) GetCourseSubCategories(ctx context.Context, arg GetCourseSubCategoriesParams) ([]GetCourseSubCategoriesRow, error) { - rows, err := q.db.Query(ctx, GetCourseSubCategories, arg.Offset, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetCourseSubCategoriesRow - for rows.Next() { - var i GetCourseSubCategoriesRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.CategoryID, - &i.CategoryName, - &i.Name, - &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 GetCourseSubCategoriesByCategoryID = `-- name: GetCourseSubCategoriesByCategoryID :many -SELECT - COUNT(*) OVER () AS total_count, - csc.id, - csc.category_id, - cc.name AS category_name, - csc.name, - csc.description, - csc.display_order, - csc.is_active, - csc.created_at -FROM course_sub_categories csc -JOIN course_categories cc ON cc.id = csc.category_id -WHERE csc.category_id = $1 - AND csc.is_active = TRUE -ORDER BY csc.display_order ASC, csc.id ASC -LIMIT $3::INT -OFFSET $2::INT -` - -type GetCourseSubCategoriesByCategoryIDParams struct { - CategoryID int64 `json:"category_id"` - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` -} - -type GetCourseSubCategoriesByCategoryIDRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - CategoryName string `json:"category_name"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -func (q *Queries) GetCourseSubCategoriesByCategoryID(ctx context.Context, arg GetCourseSubCategoriesByCategoryIDParams) ([]GetCourseSubCategoriesByCategoryIDRow, error) { - rows, err := q.db.Query(ctx, GetCourseSubCategoriesByCategoryID, arg.CategoryID, arg.Offset, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetCourseSubCategoriesByCategoryIDRow - for rows.Next() { - var i GetCourseSubCategoriesByCategoryIDRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.CategoryID, - &i.CategoryName, - &i.Name, - &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 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, - l.title AS level_title, - l.description AS level_description, - l.thumbnail AS level_thumbnail, - m.id AS module_id, - m.title AS module_title, - m.icon_url AS module_icon_url, - sm.id AS sub_module_id, - sm.title AS sub_module_title, - sm.description AS sub_module_description, - sm.thumbnail AS sub_module_thumbnail, - sm.tips AS sub_module_tips, - sm.display_order AS sub_module_display_order -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"` - LevelTitle pgtype.Text `json:"level_title"` - LevelDescription pgtype.Text `json:"level_description"` - LevelThumbnail pgtype.Text `json:"level_thumbnail"` - ModuleID pgtype.Int8 `json:"module_id"` - ModuleTitle pgtype.Text `json:"module_title"` - ModuleIconUrl pgtype.Text `json:"module_icon_url"` - SubModuleID pgtype.Int8 `json:"sub_module_id"` - SubModuleTitle pgtype.Text `json:"sub_module_title"` - SubModuleDescription pgtype.Text `json:"sub_module_description"` - SubModuleThumbnail pgtype.Text `json:"sub_module_thumbnail"` - SubModuleTips pgtype.Text `json:"sub_module_tips"` - SubModuleDisplayOrder pgtype.Int4 `json:"sub_module_display_order"` -} - -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.LevelTitle, - &i.LevelDescription, - &i.LevelThumbnail, - &i.ModuleID, - &i.ModuleTitle, - &i.ModuleIconUrl, - &i.SubModuleID, - &i.SubModuleTitle, - &i.SubModuleDescription, - &i.SubModuleThumbnail, - &i.SubModuleTips, - &i.SubModuleDisplayOrder, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetHumanLanguageCourseSubCategories = `-- name: GetHumanLanguageCourseSubCategories :many -SELECT - COUNT(*) OVER () AS total_count, - csc.id, - csc.category_id, - cc.name AS category_name, - csc.name, - csc.description, - csc.display_order, - csc.is_active, - csc.created_at -FROM course_sub_categories csc -JOIN course_categories cc ON cc.id = csc.category_id -WHERE csc.is_active = TRUE - AND cc.is_active = TRUE - AND lower(trim(cc.name)) = 'human language' -ORDER BY csc.display_order ASC, csc.id ASC -LIMIT $2::INT -OFFSET $1::INT -` - -type GetHumanLanguageCourseSubCategoriesParams struct { - Offset pgtype.Int4 `json:"offset"` - Limit pgtype.Int4 `json:"limit"` -} - -type GetHumanLanguageCourseSubCategoriesRow struct { - TotalCount int64 `json:"total_count"` - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - CategoryName string `json:"category_name"` - Name string `json:"name"` - Description pgtype.Text `json:"description"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -func (q *Queries) GetHumanLanguageCourseSubCategories(ctx context.Context, arg GetHumanLanguageCourseSubCategoriesParams) ([]GetHumanLanguageCourseSubCategoriesRow, error) { - rows, err := q.db.Query(ctx, GetHumanLanguageCourseSubCategories, arg.Offset, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetHumanLanguageCourseSubCategoriesRow - for rows.Next() { - var i GetHumanLanguageCourseSubCategoriesRow - if err := rows.Scan( - &i.TotalCount, - &i.ID, - &i.CategoryID, - &i.CategoryName, - &i.Name, - &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 GetLevelByID = `-- name: GetLevelByID :one -SELECT id, course_id, cefr_level, display_order, is_active, created_at, title, description, thumbnail -FROM levels -WHERE id = $1 -` - -func (q *Queries) GetLevelByID(ctx context.Context, id int64) (Level, error) { - row := q.db.QueryRow(ctx, GetLevelByID, id) - var i Level - err := row.Scan( - &i.ID, - &i.CourseID, - &i.CefrLevel, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.Title, - &i.Description, - &i.Thumbnail, - ) - return i, err -} - -const GetLevelsByCourseID = `-- name: GetLevelsByCourseID :many -SELECT id, course_id, cefr_level, display_order, is_active, created_at, title, description, thumbnail -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, - &i.Title, - &i.Description, - &i.Thumbnail, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetModuleByID = `-- name: GetModuleByID :one -SELECT id, level_id, title, description, display_order, is_active, created_at, icon_url -FROM modules -WHERE id = $1 -` - -func (q *Queries) GetModuleByID(ctx context.Context, id int64) (Module, error) { - row := q.db.QueryRow(ctx, GetModuleByID, id) - var i Module - err := row.Scan( - &i.ID, - &i.LevelID, - &i.Title, - &i.Description, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.IconUrl, - ) - return i, err -} - -const GetModuleCapstoneByID = `-- name: GetModuleCapstoneByID :one -SELECT - mc.id, - mc.module_id, - mc.title, - mc.description, - mc.tips, - mc.thumbnail, - mc.question_set_id, - mc.display_order, - mc.is_active, - qs.status, - qs.set_type, - qs.time_limit_minutes, - qs.passing_score, - qs.shuffle_questions, - (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count -FROM module_capstones mc -JOIN question_sets qs ON qs.id = mc.question_set_id -WHERE mc.id = $1 - AND mc.is_active = TRUE - AND qs.set_type = 'CAPSTONE' -` - -type GetModuleCapstoneByIDRow struct { - ID int64 `json:"id"` - ModuleID int64 `json:"module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Tips pgtype.Text `json:"tips"` - Thumbnail pgtype.Text `json:"thumbnail"` - 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"` - TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"` - PassingScore pgtype.Int4 `json:"passing_score"` - ShuffleQuestions bool `json:"shuffle_questions"` - QuestionCount int64 `json:"question_count"` -} - -func (q *Queries) GetModuleCapstoneByID(ctx context.Context, id int64) (GetModuleCapstoneByIDRow, error) { - row := q.db.QueryRow(ctx, GetModuleCapstoneByID, id) - var i GetModuleCapstoneByIDRow - err := row.Scan( - &i.ID, - &i.ModuleID, - &i.Title, - &i.Description, - &i.Tips, - &i.Thumbnail, - &i.QuestionSetID, - &i.DisplayOrder, - &i.IsActive, - &i.Status, - &i.SetType, - &i.TimeLimitMinutes, - &i.PassingScore, - &i.ShuffleQuestions, - &i.QuestionCount, - ) - return i, err -} - -const GetModuleCapstones = `-- name: GetModuleCapstones :many -SELECT - mc.id, - mc.module_id, - mc.title, - mc.description, - mc.tips, - mc.thumbnail, - mc.question_set_id, - mc.display_order, - mc.is_active, - qs.status, - qs.set_type, - qs.time_limit_minutes, - qs.passing_score, - qs.shuffle_questions, - (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count -FROM module_capstones mc -JOIN question_sets qs ON qs.id = mc.question_set_id -WHERE mc.module_id = $1 - AND mc.is_active = TRUE - AND qs.set_type = 'CAPSTONE' -ORDER BY mc.display_order ASC, mc.id ASC -` - -type GetModuleCapstonesRow struct { - ID int64 `json:"id"` - ModuleID int64 `json:"module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Tips pgtype.Text `json:"tips"` - Thumbnail pgtype.Text `json:"thumbnail"` - 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"` - TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"` - PassingScore pgtype.Int4 `json:"passing_score"` - ShuffleQuestions bool `json:"shuffle_questions"` - QuestionCount int64 `json:"question_count"` -} - -func (q *Queries) GetModuleCapstones(ctx context.Context, moduleID int64) ([]GetModuleCapstonesRow, error) { - rows, err := q.db.Query(ctx, GetModuleCapstones, moduleID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetModuleCapstonesRow - for rows.Next() { - var i GetModuleCapstonesRow - if err := rows.Scan( - &i.ID, - &i.ModuleID, - &i.Title, - &i.Description, - &i.Tips, - &i.Thumbnail, - &i.QuestionSetID, - &i.DisplayOrder, - &i.IsActive, - &i.Status, - &i.SetType, - &i.TimeLimitMinutes, - &i.PassingScore, - &i.ShuffleQuestions, - &i.QuestionCount, - ); 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, icon_url -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, - &i.IconUrl, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetSubModuleByID = `-- name: GetSubModuleByID :one -SELECT id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id, thumbnail, tips -FROM sub_modules -WHERE id = $1 -` - -func (q *Queries) GetSubModuleByID(ctx context.Context, id int64) (SubModule, error) { - row := q.db.QueryRow(ctx, GetSubModuleByID, id) - var i SubModule - err := row.Scan( - &i.ID, - &i.ModuleID, - &i.Title, - &i.Description, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.LegacySubCourseID, - &i.Thumbnail, - &i.Tips, - ) - return i, err -} - -const GetSubModuleCapstoneByID = `-- name: GetSubModuleCapstoneByID :one -SELECT - smc.id, - smc.sub_module_id, - smc.title, - smc.description, - smc.tips, - smc.thumbnail, - smc.question_set_id, - smc.display_order, - smc.is_active, - smc.inactive_since, - qs.status, - qs.set_type, - qs.time_limit_minutes, - qs.passing_score, - qs.shuffle_questions, - (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count -FROM sub_module_capstones smc -JOIN question_sets qs ON qs.id = smc.question_set_id -WHERE smc.id = $1 - AND smc.is_active = TRUE - AND qs.set_type = 'CAPSTONE' -` - -type GetSubModuleCapstoneByIDRow struct { - ID int64 `json:"id"` - SubModuleID int64 `json:"sub_module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Tips pgtype.Text `json:"tips"` - Thumbnail pgtype.Text `json:"thumbnail"` - QuestionSetID int64 `json:"question_set_id"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - InactiveSince pgtype.Timestamptz `json:"inactive_since"` - Status string `json:"status"` - SetType string `json:"set_type"` - TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"` - PassingScore pgtype.Int4 `json:"passing_score"` - ShuffleQuestions bool `json:"shuffle_questions"` - QuestionCount int64 `json:"question_count"` -} - -func (q *Queries) GetSubModuleCapstoneByID(ctx context.Context, id int64) (GetSubModuleCapstoneByIDRow, error) { - row := q.db.QueryRow(ctx, GetSubModuleCapstoneByID, id) - var i GetSubModuleCapstoneByIDRow - err := row.Scan( - &i.ID, - &i.SubModuleID, - &i.Title, - &i.Description, - &i.Tips, - &i.Thumbnail, - &i.QuestionSetID, - &i.DisplayOrder, - &i.IsActive, - &i.InactiveSince, - &i.Status, - &i.SetType, - &i.TimeLimitMinutes, - &i.PassingScore, - &i.ShuffleQuestions, - &i.QuestionCount, - ) - return i, err -} - -const GetSubModuleCapstones = `-- name: GetSubModuleCapstones :many -SELECT - smc.id, - smc.sub_module_id, - smc.title, - smc.description, - smc.tips, - smc.thumbnail, - smc.question_set_id, - smc.display_order, - smc.is_active, - smc.inactive_since, - qs.status, - qs.set_type, - qs.time_limit_minutes, - qs.passing_score, - qs.shuffle_questions, - (SELECT COUNT(*) FROM question_set_items qsi WHERE qsi.set_id = qs.id) AS question_count -FROM sub_module_capstones smc -JOIN question_sets qs ON qs.id = smc.question_set_id -WHERE smc.sub_module_id = $1 - AND smc.is_active = TRUE - AND qs.set_type = 'CAPSTONE' -ORDER BY smc.display_order ASC, smc.id ASC -` - -type GetSubModuleCapstonesRow struct { - ID int64 `json:"id"` - SubModuleID int64 `json:"sub_module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Tips pgtype.Text `json:"tips"` - Thumbnail pgtype.Text `json:"thumbnail"` - QuestionSetID int64 `json:"question_set_id"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - InactiveSince pgtype.Timestamptz `json:"inactive_since"` - Status string `json:"status"` - SetType string `json:"set_type"` - TimeLimitMinutes pgtype.Int4 `json:"time_limit_minutes"` - PassingScore pgtype.Int4 `json:"passing_score"` - ShuffleQuestions bool `json:"shuffle_questions"` - QuestionCount int64 `json:"question_count"` -} - -func (q *Queries) GetSubModuleCapstones(ctx context.Context, subModuleID int64) ([]GetSubModuleCapstonesRow, error) { - rows, err := q.db.Query(ctx, GetSubModuleCapstones, subModuleID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetSubModuleCapstonesRow - for rows.Next() { - var i GetSubModuleCapstonesRow - if err := rows.Scan( - &i.ID, - &i.SubModuleID, - &i.Title, - &i.Description, - &i.Tips, - &i.Thumbnail, - &i.QuestionSetID, - &i.DisplayOrder, - &i.IsActive, - &i.InactiveSince, - &i.Status, - &i.SetType, - &i.TimeLimitMinutes, - &i.PassingScore, - &i.ShuffleQuestions, - &i.QuestionCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetSubModuleLessonByID = `-- name: GetSubModuleLessonByID :one -SELECT id, sub_module_id, display_order, is_active, created_at, title, description, thumbnail, teaching_text, teaching_image_url, teaching_audio_url, teaching_video_url, inactive_since -FROM sub_module_lessons -WHERE id = $1 -` - -func (q *Queries) GetSubModuleLessonByID(ctx context.Context, id int64) (SubModuleLesson, error) { - row := q.db.QueryRow(ctx, GetSubModuleLessonByID, id) - var i SubModuleLesson - err := row.Scan( - &i.ID, - &i.SubModuleID, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.TeachingText, - &i.TeachingImageUrl, - &i.TeachingAudioUrl, - &i.TeachingVideoUrl, - &i.InactiveSince, - ) - return i, err -} - -const GetSubModuleLessons = `-- name: GetSubModuleLessons :many -SELECT id, sub_module_id, display_order, is_active, created_at, title, description, thumbnail, teaching_text, teaching_image_url, teaching_audio_url, teaching_video_url, inactive_since -FROM sub_module_lessons -WHERE sub_module_id = $1 - AND is_active = TRUE -ORDER BY display_order ASC, id ASC -` - -func (q *Queries) GetSubModuleLessons(ctx context.Context, subModuleID int64) ([]SubModuleLesson, error) { - rows, err := q.db.Query(ctx, GetSubModuleLessons, subModuleID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []SubModuleLesson - for rows.Next() { - var i SubModuleLesson - if err := rows.Scan( - &i.ID, - &i.SubModuleID, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.TeachingText, - &i.TeachingImageUrl, - &i.TeachingAudioUrl, - &i.TeachingVideoUrl, - &i.InactiveSince, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetSubModuleLessonsAll = `-- name: GetSubModuleLessonsAll :many -SELECT id, sub_module_id, display_order, is_active, created_at, title, description, thumbnail, teaching_text, teaching_image_url, teaching_audio_url, teaching_video_url, inactive_since -FROM sub_module_lessons -WHERE sub_module_id = $1 -ORDER BY display_order ASC, id ASC -` - -func (q *Queries) GetSubModuleLessonsAll(ctx context.Context, subModuleID int64) ([]SubModuleLesson, error) { - rows, err := q.db.Query(ctx, GetSubModuleLessonsAll, subModuleID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []SubModuleLesson - for rows.Next() { - var i SubModuleLesson - if err := rows.Scan( - &i.ID, - &i.SubModuleID, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.TeachingText, - &i.TeachingImageUrl, - &i.TeachingAudioUrl, - &i.TeachingVideoUrl, - &i.InactiveSince, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const GetSubModulePracticeByID = `-- name: GetSubModulePracticeByID :one -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, - smp.inactive_since, - 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.is_active = TRUE - AND (smp.id = $1 OR smp.question_set_id = $1) -ORDER BY (smp.id = $1) DESC -LIMIT 1 -` - -type GetSubModulePracticeByIDRow 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"` - InactiveSince pgtype.Timestamptz `json:"inactive_since"` - Status string `json:"status"` - SetType string `json:"set_type"` - QuestionCount int64 `json:"question_count"` -} - -func (q *Queries) GetSubModulePracticeByID(ctx context.Context, id int64) (GetSubModulePracticeByIDRow, error) { - row := q.db.QueryRow(ctx, GetSubModulePracticeByID, id) - var i GetSubModulePracticeByIDRow - err := row.Scan( - &i.ID, - &i.SubModuleID, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.IntroVideoUrl, - &i.QuestionSetID, - &i.DisplayOrder, - &i.IsActive, - &i.InactiveSince, - &i.Status, - &i.SetType, - &i.QuestionCount, - ) - return i, err -} - -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, - smp.inactive_since, - 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"` - InactiveSince pgtype.Timestamptz `json:"inactive_since"` - 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.InactiveSince, - &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, thumbnail, tips -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, - &i.Thumbnail, - &i.Tips, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const PurgeInactiveSubModuleCapstonesBefore = `-- name: PurgeInactiveSubModuleCapstonesBefore :execrows -DELETE FROM question_sets qs -USING ( - SELECT question_set_id - FROM sub_module_capstones - WHERE is_active = FALSE - AND inactive_since IS NOT NULL - AND inactive_since < $1 -) doomed -WHERE qs.id = doomed.question_set_id -` - -func (q *Queries) PurgeInactiveSubModuleCapstonesBefore(ctx context.Context, inactiveSince pgtype.Timestamptz) (int64, error) { - result, err := q.db.Exec(ctx, PurgeInactiveSubModuleCapstonesBefore, inactiveSince) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const PurgeInactiveSubModuleLessonsBefore = `-- name: PurgeInactiveSubModuleLessonsBefore :execrows -DELETE FROM sub_module_lessons -WHERE is_active = FALSE - AND inactive_since IS NOT NULL - AND inactive_since < $1 -` - -func (q *Queries) PurgeInactiveSubModuleLessonsBefore(ctx context.Context, inactiveSince pgtype.Timestamptz) (int64, error) { - result, err := q.db.Exec(ctx, PurgeInactiveSubModuleLessonsBefore, inactiveSince) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const PurgeInactiveSubModulePracticesBefore = `-- name: PurgeInactiveSubModulePracticesBefore :execrows -DELETE FROM question_sets qs -USING ( - SELECT question_set_id - FROM sub_module_practices - WHERE is_active = FALSE - AND inactive_since IS NOT NULL - AND inactive_since < $1 -) doomed -WHERE qs.id = doomed.question_set_id -` - -func (q *Queries) PurgeInactiveSubModulePracticesBefore(ctx context.Context, inactiveSince pgtype.Timestamptz) (int64, error) { - result, err := q.db.Exec(ctx, PurgeInactiveSubModulePracticesBefore, inactiveSince) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const UpdateLevel = `-- name: UpdateLevel :one -UPDATE levels -SET - title = $1, - description = $2, - thumbnail = $3, - display_order = $4, - is_active = $5 -WHERE id = $6 -RETURNING id, course_id, cefr_level, display_order, is_active, created_at, title, description, thumbnail -` - -type UpdateLevelParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateLevel(ctx context.Context, arg UpdateLevelParams) (Level, error) { - row := q.db.QueryRow(ctx, UpdateLevel, - arg.Title, - arg.Description, - arg.Thumbnail, - arg.DisplayOrder, - arg.IsActive, - arg.ID, - ) - var i Level - err := row.Scan( - &i.ID, - &i.CourseID, - &i.CefrLevel, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.Title, - &i.Description, - &i.Thumbnail, - ) - return i, err -} - -const UpdateModule = `-- name: UpdateModule :one -UPDATE modules -SET - title = $1, - description = $2, - icon_url = $3, - display_order = $4, - is_active = $5 -WHERE id = $6 -RETURNING id, level_id, title, description, display_order, is_active, created_at, icon_url -` - -type UpdateModuleParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - IconUrl pgtype.Text `json:"icon_url"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateModule(ctx context.Context, arg UpdateModuleParams) (Module, error) { - row := q.db.QueryRow(ctx, UpdateModule, - arg.Title, - arg.Description, - arg.IconUrl, - arg.DisplayOrder, - arg.IsActive, - arg.ID, - ) - var i Module - err := row.Scan( - &i.ID, - &i.LevelID, - &i.Title, - &i.Description, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.IconUrl, - ) - return i, err -} - -const UpdateModuleCapstone = `-- name: UpdateModuleCapstone :one -UPDATE module_capstones -SET - title = $1, - description = $2, - tips = $3, - thumbnail = $4, - display_order = $5, - is_active = $6 -WHERE id = $7 -RETURNING id, module_id, title, description, tips, thumbnail, question_set_id, display_order, is_active, created_at -` - -type UpdateModuleCapstoneParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Tips pgtype.Text `json:"tips"` - Thumbnail pgtype.Text `json:"thumbnail"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateModuleCapstone(ctx context.Context, arg UpdateModuleCapstoneParams) (ModuleCapstone, error) { - row := q.db.QueryRow(ctx, UpdateModuleCapstone, - arg.Title, - arg.Description, - arg.Tips, - arg.Thumbnail, - arg.DisplayOrder, - arg.IsActive, - arg.ID, - ) - var i ModuleCapstone - err := row.Scan( - &i.ID, - &i.ModuleID, - &i.Title, - &i.Description, - &i.Tips, - &i.Thumbnail, - &i.QuestionSetID, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - ) - return i, err -} - -const UpdateSubModule = `-- name: UpdateSubModule :one -UPDATE sub_modules -SET - title = $1, - description = $2, - thumbnail = $3, - tips = $4, - display_order = $5, - is_active = $6 -WHERE id = $7 -RETURNING id, module_id, title, description, display_order, is_active, created_at, legacy_sub_course_id, thumbnail, tips -` - -type UpdateSubModuleParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - Tips pgtype.Text `json:"tips"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateSubModule(ctx context.Context, arg UpdateSubModuleParams) (SubModule, error) { - row := q.db.QueryRow(ctx, UpdateSubModule, - arg.Title, - arg.Description, - arg.Thumbnail, - arg.Tips, - arg.DisplayOrder, - arg.IsActive, - arg.ID, - ) - var i SubModule - err := row.Scan( - &i.ID, - &i.ModuleID, - &i.Title, - &i.Description, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.LegacySubCourseID, - &i.Thumbnail, - &i.Tips, - ) - return i, err -} - -const UpdateSubModuleCapstone = `-- name: UpdateSubModuleCapstone :one -UPDATE sub_module_capstones -SET - title = $1, - description = $2, - tips = $3, - thumbnail = $4, - display_order = $5, - is_active = $6, - inactive_since = CASE - WHEN $6 THEN NULL - WHEN is_active THEN NOW() - ELSE inactive_since - END -WHERE id = $7 -RETURNING id, sub_module_id, title, description, tips, thumbnail, question_set_id, display_order, is_active, created_at, inactive_since -` - -type UpdateSubModuleCapstoneParams struct { - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Tips pgtype.Text `json:"tips"` - Thumbnail pgtype.Text `json:"thumbnail"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateSubModuleCapstone(ctx context.Context, arg UpdateSubModuleCapstoneParams) (SubModuleCapstone, error) { - row := q.db.QueryRow(ctx, UpdateSubModuleCapstone, - arg.Title, - arg.Description, - arg.Tips, - arg.Thumbnail, - arg.DisplayOrder, - arg.IsActive, - arg.ID, - ) - var i SubModuleCapstone - err := row.Scan( - &i.ID, - &i.SubModuleID, - &i.Title, - &i.Description, - &i.Tips, - &i.Thumbnail, - &i.QuestionSetID, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.InactiveSince, - ) - return i, err -} - -const UpdateSubModuleLesson = `-- name: UpdateSubModuleLesson :one -UPDATE sub_module_lessons -SET - sub_module_id = $1, - title = $2, - description = $3, - thumbnail = $4, - teaching_text = $5, - teaching_image_url = $6, - teaching_audio_url = $7, - teaching_video_url = $8, - display_order = $9, - is_active = $10, - inactive_since = CASE - WHEN $10 THEN NULL - WHEN is_active THEN NOW() - ELSE inactive_since - END -WHERE id = $11 -RETURNING id, sub_module_id, display_order, is_active, created_at, title, description, thumbnail, teaching_text, teaching_image_url, teaching_audio_url, teaching_video_url, inactive_since -` - -type UpdateSubModuleLessonParams struct { - SubModuleID int64 `json:"sub_module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - TeachingText pgtype.Text `json:"teaching_text"` - TeachingImageUrl pgtype.Text `json:"teaching_image_url"` - TeachingAudioUrl pgtype.Text `json:"teaching_audio_url"` - TeachingVideoUrl pgtype.Text `json:"teaching_video_url"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateSubModuleLesson(ctx context.Context, arg UpdateSubModuleLessonParams) (SubModuleLesson, error) { - row := q.db.QueryRow(ctx, UpdateSubModuleLesson, - arg.SubModuleID, - arg.Title, - arg.Description, - arg.Thumbnail, - arg.TeachingText, - arg.TeachingImageUrl, - arg.TeachingAudioUrl, - arg.TeachingVideoUrl, - arg.DisplayOrder, - arg.IsActive, - arg.ID, - ) - var i SubModuleLesson - err := row.Scan( - &i.ID, - &i.SubModuleID, - &i.DisplayOrder, - &i.IsActive, - &i.CreatedAt, - &i.Title, - &i.Description, - &i.Thumbnail, - &i.TeachingText, - &i.TeachingImageUrl, - &i.TeachingAudioUrl, - &i.TeachingVideoUrl, - &i.InactiveSince, - ) - return i, err -} diff --git a/gen/db/models.go b/gen/db/models.go index 0310673..2716bc3 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -22,36 +22,6 @@ type ActivityLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } -type Course struct { - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - IsActive bool `json:"is_active"` - 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 { - ID int64 `json:"id"` - Name string `json:"name"` - IsActive bool `json:"is_active"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - 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"` @@ -69,47 +39,11 @@ 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"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` -} - 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"` - IconUrl pgtype.Text `json:"icon_url"` -} - -type ModuleCapstone struct { - ID int64 `json:"id"` - ModuleID int64 `json:"module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Tips pgtype.Text `json:"tips"` - Thumbnail pgtype.Text `json:"thumbnail"` - QuestionSetID int64 `json:"question_set_id"` - 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"` @@ -171,6 +105,15 @@ type Permission struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type Program struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Question struct { ID int64 `json:"id"` QuestionText string `json:"question_text"` @@ -218,7 +161,6 @@ type QuestionSet struct { Status string `json:"status"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` - SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"` DisplayOrder int32 `json:"display_order"` IntroVideoUrl pgtype.Text `json:"intro_video_url"` } @@ -315,132 +257,6 @@ type ScheduledNotification struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } -type SubCourse 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"` - IsActive bool `json:"is_active"` - SubLevel string `json:"sub_level"` -} - -type SubCoursePrerequisite struct { - ID int64 `json:"id"` - SubCourseID int64 `json:"sub_course_id"` - PrerequisiteSubCourseID int64 `json:"prerequisite_sub_course_id"` - CreatedAt pgtype.Timestamptz `json:"created_at"` -} - -type SubCourseVideo struct { - 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"` - // Vimeo video ID for videos hosted on Vimeo - VimeoID pgtype.Text `json:"vimeo_id"` - // Vimeo player embed URL - VimeoEmbedUrl pgtype.Text `json:"vimeo_embed_url"` - // Vimeo iframe embed HTML code - VimeoPlayerHtml pgtype.Text `json:"vimeo_player_html"` - // Vimeo video status: pending, uploading, transcoding, available, error - VimeoStatus pgtype.Text `json:"vimeo_status"` - // Video hosting provider: DIRECT or VIMEO - 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"` - Thumbnail pgtype.Text `json:"thumbnail"` - Tips pgtype.Text `json:"tips"` -} - -type SubModuleCapstone struct { - ID int64 `json:"id"` - SubModuleID int64 `json:"sub_module_id"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Tips pgtype.Text `json:"tips"` - Thumbnail pgtype.Text `json:"thumbnail"` - QuestionSetID int64 `json:"question_set_id"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - InactiveSince pgtype.Timestamptz `json:"inactive_since"` -} - -type SubModuleLesson struct { - ID int64 `json:"id"` - SubModuleID int64 `json:"sub_module_id"` - DisplayOrder int32 `json:"display_order"` - IsActive bool `json:"is_active"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - TeachingText pgtype.Text `json:"teaching_text"` - TeachingImageUrl pgtype.Text `json:"teaching_image_url"` - TeachingAudioUrl pgtype.Text `json:"teaching_audio_url"` - TeachingVideoUrl pgtype.Text `json:"teaching_video_url"` - InactiveSince pgtype.Timestamptz `json:"inactive_since"` -} - -type SubModulePractice 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"` - Title string `json:"title"` - Description pgtype.Text `json:"description"` - Thumbnail pgtype.Text `json:"thumbnail"` - InactiveSince pgtype.Timestamptz `json:"inactive_since"` -} - -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"` @@ -538,35 +354,12 @@ type UserAudioResponse struct { } type UserPracticeProgress struct { - ID int64 `json:"id"` - UserID int64 `json:"user_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"` - UpdatedAt pgtype.Timestamp `json:"updated_at"` -} - -type UserSubCourseProgress 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"` -} - -type UserSubCourseVideoProgress struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - SubCourseID int64 `json:"sub_course_id"` - VideoID int64 `json:"video_id"` - CompletedAt pgtype.Timestamp `json:"completed_at"` - CreatedAt pgtype.Timestamp `json:"created_at"` - UpdatedAt pgtype.Timestamp `json:"updated_at"` + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + QuestionSetID int64 `json:"question_set_id"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type UserSubscription struct { diff --git a/gen/db/practice_progress.sql.go b/gen/db/practice_progress.sql.go index 1dc8225..4819130 100644 --- a/gen/db/practice_progress.sql.go +++ b/gen/db/practice_progress.sql.go @@ -59,26 +59,16 @@ func (q *Queries) GetFirstIncompletePreviousPractice(ctx context.Context, arg Ge const MarkPracticeCompleted = `-- name: MarkPracticeCompleted :execrows INSERT INTO user_practice_progress ( user_id, - sub_course_id, question_set_id, completed_at, updated_at ) -SELECT +VALUES ( $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, + $2::BIGINT, 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 diff --git a/gen/db/programs.sql.go b/gen/db/programs.sql.go new file mode 100644 index 0000000..9f59a69 --- /dev/null +++ b/gen/db/programs.sql.go @@ -0,0 +1,162 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: programs.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CreateProgram = `-- name: CreateProgram :one +INSERT INTO programs (name, description, thumbnail) +VALUES ($1, $2, $3) +RETURNING id, name, description, thumbnail, created_at, updated_at +` + +type CreateProgramParams struct { + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` +} + +func (q *Queries) CreateProgram(ctx context.Context, arg CreateProgramParams) (Program, error) { + row := q.db.QueryRow(ctx, CreateProgram, arg.Name, arg.Description, arg.Thumbnail) + var i Program + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Thumbnail, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const DeleteProgram = `-- name: DeleteProgram :exec +DELETE FROM programs +WHERE id = $1 +` + +func (q *Queries) DeleteProgram(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, DeleteProgram, id) + return err +} + +const GetProgramByID = `-- name: GetProgramByID :one +SELECT id, name, description, thumbnail, created_at, updated_at +FROM programs +WHERE id = $1 +` + +func (q *Queries) GetProgramByID(ctx context.Context, id int64) (Program, error) { + row := q.db.QueryRow(ctx, GetProgramByID, id) + var i Program + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Thumbnail, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const ListPrograms = `-- name: ListPrograms :many +SELECT + COUNT(*) OVER () AS total_count, + p.id, + p.name, + p.description, + p.thumbnail, + p.created_at, + p.updated_at +FROM programs p +ORDER BY p.created_at DESC +LIMIT $1 OFFSET $2 +` + +type ListProgramsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type ListProgramsRow struct { + TotalCount int64 `json:"total_count"` + ID int64 `json:"id"` + Name string `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListPrograms(ctx context.Context, arg ListProgramsParams) ([]ListProgramsRow, error) { + rows, err := q.db.Query(ctx, ListPrograms, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListProgramsRow + for rows.Next() { + var i ListProgramsRow + if err := rows.Scan( + &i.TotalCount, + &i.ID, + &i.Name, + &i.Description, + &i.Thumbnail, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpdateProgram = `-- name: UpdateProgram :one +UPDATE programs +SET + name = COALESCE($1::varchar, name), + description = COALESCE($2::text, description), + thumbnail = COALESCE($3::text, thumbnail), + updated_at = CURRENT_TIMESTAMP +WHERE id = $4 +RETURNING id, name, description, thumbnail, created_at, updated_at +` + +type UpdateProgramParams struct { + Name pgtype.Text `json:"name"` + Description pgtype.Text `json:"description"` + Thumbnail pgtype.Text `json:"thumbnail"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateProgram(ctx context.Context, arg UpdateProgramParams) (Program, error) { + row := q.db.QueryRow(ctx, UpdateProgram, + arg.Name, + arg.Description, + arg.Thumbnail, + arg.ID, + ) + var i Program + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Thumbnail, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/gen/db/question_set_items.sql.go b/gen/db/question_set_items.sql.go index eafc332..d1a0860 100644 --- a/gen/db/question_set_items.sql.go +++ b/gen/db/question_set_items.sql.go @@ -295,7 +295,7 @@ func (q *Queries) GetQuestionSetItemsPaginated(ctx context.Context, arg GetQuest } const GetQuestionSetsContainingQuestion = `-- name: GetQuestionSetsContainingQuestion :many -SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order, qs.intro_video_url +SELECT qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.display_order, qs.intro_video_url FROM question_sets qs JOIN question_set_items qsi ON qsi.set_id = qs.id WHERE qsi.question_id = $1 @@ -326,7 +326,6 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio &i.Status, &i.CreatedAt, &i.UpdatedAt, - &i.SubCourseVideoID, &i.DisplayOrder, &i.IntroVideoUrl, ); err != nil { diff --git a/gen/db/question_sets.sql.go b/gen/db/question_sets.sql.go index 70167c9..d418b77 100644 --- a/gen/db/question_sets.sql.go +++ b/gen/db/question_sets.sql.go @@ -64,11 +64,10 @@ INSERT INTO question_sets ( passing_score, shuffle_questions, status, - sub_course_video_id, intro_video_url ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12, $13) -RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, false), COALESCE($11, 'DRAFT'), $12) +RETURNING id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url ` type CreateQuestionSetParams struct { @@ -83,7 +82,6 @@ type CreateQuestionSetParams struct { PassingScore pgtype.Int4 `json:"passing_score"` Column10 interface{} `json:"column_10"` Column11 interface{} `json:"column_11"` - SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"` IntroVideoUrl pgtype.Text `json:"intro_video_url"` } @@ -100,7 +98,6 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa arg.PassingScore, arg.Column10, arg.Column11, - arg.SubCourseVideoID, arg.IntroVideoUrl, ) var i QuestionSet @@ -119,7 +116,6 @@ func (q *Queries) CreateQuestionSet(ctx context.Context, arg CreateQuestionSetPa &i.Status, &i.CreatedAt, &i.UpdatedAt, - &i.SubCourseVideoID, &i.DisplayOrder, &i.IntroVideoUrl, ) @@ -137,7 +133,7 @@ func (q *Queries) DeleteQuestionSet(ctx context.Context, id int64) error { } const GetInitialAssessmentSet = `-- name: GetInitialAssessmentSet :one -SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url +SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url FROM question_sets WHERE set_type = 'INITIAL_ASSESSMENT' AND status = 'PUBLISHED' @@ -163,7 +159,6 @@ func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, err &i.Status, &i.CreatedAt, &i.UpdatedAt, - &i.SubCourseVideoID, &i.DisplayOrder, &i.IntroVideoUrl, ) @@ -171,7 +166,7 @@ func (q *Queries) GetInitialAssessmentSet(ctx context.Context) (QuestionSet, err } const GetPublishedQuestionSetsByOwner = `-- name: GetPublishedQuestionSetsByOwner :many -SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url +SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url FROM question_sets WHERE owner_type = $1 AND owner_id = $2 @@ -208,7 +203,6 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu &i.Status, &i.CreatedAt, &i.UpdatedAt, - &i.SubCourseVideoID, &i.DisplayOrder, &i.IntroVideoUrl, ); err != nil { @@ -223,7 +217,7 @@ func (q *Queries) GetPublishedQuestionSetsByOwner(ctx context.Context, arg GetPu } const GetQuestionSetByID = `-- name: GetQuestionSetByID :one -SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url +SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url FROM question_sets WHERE id = $1 ` @@ -246,7 +240,6 @@ func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet &i.Status, &i.CreatedAt, &i.UpdatedAt, - &i.SubCourseVideoID, &i.DisplayOrder, &i.IntroVideoUrl, ) @@ -254,7 +247,7 @@ func (q *Queries) GetQuestionSetByID(ctx context.Context, id int64) (QuestionSet } const GetQuestionSetsByOwner = `-- name: GetQuestionSetsByOwner :many -SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url +SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, display_order, intro_video_url FROM question_sets WHERE owner_type = $1 AND owner_id = $2 @@ -291,7 +284,6 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet &i.Status, &i.CreatedAt, &i.UpdatedAt, - &i.SubCourseVideoID, &i.DisplayOrder, &i.IntroVideoUrl, ); err != nil { @@ -308,7 +300,7 @@ func (q *Queries) GetQuestionSetsByOwner(ctx context.Context, arg GetQuestionSet const GetQuestionSetsByType = `-- name: GetQuestionSetsByType :many SELECT COUNT(*) OVER () AS total_count, - qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.sub_course_video_id, qs.display_order, qs.intro_video_url + qs.id, qs.title, qs.description, qs.set_type, qs.owner_type, qs.owner_id, qs.banner_image, qs.persona, qs.time_limit_minutes, qs.passing_score, qs.shuffle_questions, qs.status, qs.created_at, qs.updated_at, qs.display_order, qs.intro_video_url FROM question_sets qs WHERE set_type = $1 AND status != 'ARCHIVED' @@ -339,7 +331,6 @@ type GetQuestionSetsByTypeRow struct { Status string `json:"status"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` - SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"` DisplayOrder int32 `json:"display_order"` IntroVideoUrl pgtype.Text `json:"intro_video_url"` } @@ -369,7 +360,6 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets &i.Status, &i.CreatedAt, &i.UpdatedAt, - &i.SubCourseVideoID, &i.DisplayOrder, &i.IntroVideoUrl, ); err != nil { @@ -383,42 +373,6 @@ func (q *Queries) GetQuestionSetsByType(ctx context.Context, arg GetQuestionSets return items, nil } -const GetSubCourseInitialAssessmentSet = `-- name: GetSubCourseInitialAssessmentSet :one -SELECT id, title, description, set_type, owner_type, owner_id, banner_image, persona, time_limit_minutes, passing_score, shuffle_questions, status, created_at, updated_at, sub_course_video_id, display_order, intro_video_url -FROM question_sets -WHERE set_type = 'INITIAL_ASSESSMENT' - AND owner_type = 'SUB_COURSE' - AND owner_id = $1 - AND status = 'PUBLISHED' -ORDER BY created_at DESC -LIMIT 1 -` - -func (q *Queries) GetSubCourseInitialAssessmentSet(ctx context.Context, ownerID pgtype.Int8) (QuestionSet, error) { - row := q.db.QueryRow(ctx, GetSubCourseInitialAssessmentSet, ownerID) - var i QuestionSet - err := row.Scan( - &i.ID, - &i.Title, - &i.Description, - &i.SetType, - &i.OwnerType, - &i.OwnerID, - &i.BannerImage, - &i.Persona, - &i.TimeLimitMinutes, - &i.PassingScore, - &i.ShuffleQuestions, - &i.Status, - &i.CreatedAt, - &i.UpdatedAt, - &i.SubCourseVideoID, - &i.DisplayOrder, - &i.IntroVideoUrl, - ) - return i, err -} - const GetUserPersonasByQuestionSetID = `-- name: GetUserPersonasByQuestionSetID :many SELECT u.id, @@ -519,9 +473,8 @@ SET shuffle_questions = COALESCE($7, shuffle_questions), status = COALESCE($8, status), intro_video_url = COALESCE($9, intro_video_url), - sub_course_video_id = COALESCE($10, sub_course_video_id), updated_at = CURRENT_TIMESTAMP -WHERE id = $11 +WHERE id = $10 ` type UpdateQuestionSetParams struct { @@ -534,7 +487,6 @@ type UpdateQuestionSetParams struct { ShuffleQuestions bool `json:"shuffle_questions"` Status string `json:"status"` IntroVideoUrl pgtype.Text `json:"intro_video_url"` - SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"` ID int64 `json:"id"` } @@ -549,26 +501,7 @@ func (q *Queries) UpdateQuestionSet(ctx context.Context, arg UpdateQuestionSetPa arg.ShuffleQuestions, arg.Status, arg.IntroVideoUrl, - arg.SubCourseVideoID, arg.ID, ) return err } - -const UpdateQuestionSetVideoLink = `-- name: UpdateQuestionSetVideoLink :exec -UPDATE question_sets -SET - sub_course_video_id = $1, - updated_at = CURRENT_TIMESTAMP -WHERE id = $2 -` - -type UpdateQuestionSetVideoLinkParams struct { - SubCourseVideoID pgtype.Int8 `json:"sub_course_video_id"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateQuestionSetVideoLink(ctx context.Context, arg UpdateQuestionSetVideoLinkParams) error { - _, err := q.db.Exec(ctx, UpdateQuestionSetVideoLink, arg.SubCourseVideoID, arg.ID) - return err -} diff --git a/internal/domain/activity_log.go b/internal/domain/activity_log.go index 5579361..1327c3c 100644 --- a/internal/domain/activity_log.go +++ b/internal/domain/activity_log.go @@ -45,6 +45,9 @@ const ( ActionIssueCreated ActivityAction = "ISSUE_CREATED" ActionIssueStatusUpdated ActivityAction = "ISSUE_STATUS_UPDATED" ActionIssueDeleted ActivityAction = "ISSUE_DELETED" + ActionProgramCreated ActivityAction = "PROGRAM_CREATED" + ActionProgramUpdated ActivityAction = "PROGRAM_UPDATED" + ActionProgramDeleted ActivityAction = "PROGRAM_DELETED" ) type ResourceType string @@ -62,6 +65,7 @@ const ( ResourceQuestion ResourceType = "QUESTION" ResourceQuestionSet ResourceType = "QUESTION_SET" ResourceIssue ResourceType = "ISSUE" + ResourceProgram ResourceType = "PROGRAM" ) type ActivityLog struct { diff --git a/internal/domain/course_management.go b/internal/domain/course_management.go deleted file mode 100644 index f94c24f..0000000 --- a/internal/domain/course_management.go +++ /dev/null @@ -1,170 +0,0 @@ -package domain - -import "time" - -type SubCourseLevel string - -const ( - SubCourseLevelBeginner SubCourseLevel = "BEGINNER" - SubCourseLevelIntermediate SubCourseLevel = "INTERMEDIATE" - SubCourseLevelAdvanced SubCourseLevel = "ADVANCED" -) - -type SubCourseSubLevel string - -const ( - SubCourseSubLevelA1 SubCourseSubLevel = "A1" - SubCourseSubLevelA2 SubCourseSubLevel = "A2" - SubCourseSubLevelA3 SubCourseSubLevel = "A3" - SubCourseSubLevelB1 SubCourseSubLevel = "B1" - SubCourseSubLevelB2 SubCourseSubLevel = "B2" - SubCourseSubLevelB3 SubCourseSubLevel = "B3" - SubCourseSubLevelC1 SubCourseSubLevel = "C1" - SubCourseSubLevelC2 SubCourseSubLevel = "C2" - SubCourseSubLevelC3 SubCourseSubLevel = "C3" -) - -type ContentStatus string - -const ( - ContentStatusDraft ContentStatus = "DRAFT" - ContentStatusPublished ContentStatus = "PUBLISHED" - ContentStatusInactive ContentStatus = "INACTIVE" - ContentStatusArchived ContentStatus = "ARCHIVED" -) - -type TreeSubCourse struct { - ID int64 - Title string - Level string - SubLevel string -} - -type TreeCourse struct { - ID int64 - Title string - SubCourses []TreeSubCourse -} - -type CourseCategory struct { - ID int64 - Name string - IsActive bool - CreatedAt time.Time -} - -type Course struct { - ID int64 - CategoryID int64 - Title string - Description *string - Thumbnail *string - IntroVideoURL *string - IsActive bool -} - -type SubCourse struct { - ID int64 - CourseID int64 - Title string - Description *string - Thumbnail *string - DisplayOrder int32 - Level string - SubLevel string - IsActive bool -} - -type SubCourseVideo struct { - ID int64 - SubCourseID int64 - Title string - Description *string - VideoURL string - Duration int32 - Resolution *string - InstructorID *string - Thumbnail *string - Visibility *string - DisplayOrder int32 - IsPublished bool - PublishDate *time.Time - Status string - // Vimeo-specific fields - VimeoID *string - VimeoEmbedURL *string - VimeoPlayerHTML *string - VimeoStatus *string -} - -type VideoHostProvider string - -const ( - VideoHostProviderDirect VideoHostProvider = "DIRECT" - VideoHostProviderVimeo VideoHostProvider = "VIMEO" -) - -type VideoAccessBlock struct { - VideoID int64 - Title string - DisplayOrder int32 -} - -// Learning Path types — full nested structure for a course -type LearningPathVideo struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description *string `json:"description,omitempty"` - VideoURL string `json:"video_url"` - Duration int32 `json:"duration"` - Resolution *string `json:"resolution,omitempty"` - DisplayOrder int32 `json:"display_order"` - VimeoID *string `json:"vimeo_id,omitempty"` - VimeoEmbedURL *string `json:"vimeo_embed_url,omitempty"` - VideoHostProvider *string `json:"video_host_provider,omitempty"` -} - -type LearningPathPractice struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description *string `json:"description,omitempty"` - Persona *string `json:"persona,omitempty"` - Status string `json:"status"` - IntroVideoURL *string `json:"intro_video_url,omitempty"` - QuestionCount int64 `json:"question_count"` -} - -type LearningPathPrerequisite struct { - SubCourseID int64 `json:"sub_course_id"` - Title string `json:"title"` - Level string `json:"level"` - SubLevel string `json:"sub_level"` -} - -type LearningPathSubCourse struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description *string `json:"description,omitempty"` - Thumbnail *string `json:"thumbnail,omitempty"` - Tips *string `json:"tips,omitempty"` - DisplayOrder int32 `json:"display_order"` - Level string `json:"level"` - SubLevel string `json:"sub_level"` - PrerequisiteCount int64 `json:"prerequisite_count"` - VideoCount int64 `json:"video_count"` - PracticeCount int64 `json:"practice_count"` - Prerequisites []LearningPathPrerequisite `json:"prerequisites"` - Videos []LearningPathVideo `json:"videos"` - Practices []LearningPathPractice `json:"practices"` -} - -type LearningPath struct { - CourseID int64 `json:"course_id"` - CourseTitle string `json:"course_title"` - Description *string `json:"description,omitempty"` - Thumbnail *string `json:"thumbnail,omitempty"` - IntroVideoURL *string `json:"intro_video_url,omitempty"` - CategoryID int64 `json:"category_id"` - CategoryName string `json:"category_name"` - SubCourses []LearningPathSubCourse `json:"sub_courses"` -} diff --git a/internal/domain/program.go b/internal/domain/program.go new file mode 100644 index 0000000..dca0715 --- /dev/null +++ b/internal/domain/program.go @@ -0,0 +1,25 @@ +package domain + +import "time" + +// Program is the top-level container in the LMS hierarchy (e.g. tracks like Beginner / Intermediate / Advanced). +type Program struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type CreateProgramInput struct { + Name string `json:"name" validate:"required"` + Description *string `json:"description,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` +} + +type UpdateProgramInput struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` +} diff --git a/internal/domain/progression.go b/internal/domain/progression.go deleted file mode 100644 index 692770d..0000000 --- a/internal/domain/progression.go +++ /dev/null @@ -1,84 +0,0 @@ -package domain - -import ( - "errors" - "time" -) - -var ( - ErrPrerequisiteNotMet = errors.New("prerequisites not completed") - ErrProgressNotFound = errors.New("progress record not found") - ErrPrerequisiteExists = errors.New("prerequisite already exists") - ErrSelfPrerequisite = errors.New("sub-course cannot be its own prerequisite") - ErrSubCourseAlreadyStarted = errors.New("sub-course already started") -) - -type SubCoursePrerequisite struct { - ID int64 - SubCourseID int64 - PrerequisiteSubCourseID int64 - PrerequisiteTitle string - PrerequisiteLevel string - PrerequisiteDisplayOrder int32 - CreatedAt time.Time -} - -type SubCourseDependent struct { - ID int64 - SubCourseID int64 - PrerequisiteSubCourseID int64 - DependentTitle string - DependentLevel string - CreatedAt time.Time -} - -type ProgressStatus string - -const ( - ProgressStatusNotStarted ProgressStatus = "NOT_STARTED" - ProgressStatusInProgress ProgressStatus = "IN_PROGRESS" - ProgressStatusCompleted ProgressStatus = "COMPLETED" -) - -type UserSubCourseProgress struct { - ID int64 - UserID int64 - SubCourseID int64 - Status ProgressStatus - ProgressPercentage int16 - StartedAt *time.Time - CompletedAt *time.Time - CreatedAt time.Time - UpdatedAt *time.Time -} - -type SubCourseWithProgress struct { - SubCourseID int64 - Title string - Description *string - Thumbnail *string - DisplayOrder int32 - Level string - IsActive bool - ProgressStatus ProgressStatus - ProgressPercentage int16 - StartedAt *time.Time - CompletedAt *time.Time - UnmetPrerequisitesCount int64 - IsLocked bool -} - -type UserCourseProgressItem struct { - ID int64 - UserID int64 - SubCourseID int64 - Status ProgressStatus - ProgressPercentage int16 - StartedAt *time.Time - CompletedAt *time.Time - CreatedAt time.Time - UpdatedAt *time.Time - SubCourseTitle string - SubCourseLevel string - SubCourseDisplayOrder int32 -} diff --git a/internal/domain/questions.go b/internal/domain/questions.go index 13bcf3b..25d481a 100644 --- a/internal/domain/questions.go +++ b/internal/domain/questions.go @@ -104,7 +104,6 @@ type QuestionSet struct { PassingScore *int32 ShuffleQuestions bool Status string - SubCourseVideoID *int64 IntroVideoURL *string UserPersonas []UserPersona CreatedAt time.Time @@ -173,7 +172,6 @@ type CreateQuestionSetInput struct { PassingScore *int32 ShuffleQuestions *bool Status *string - SubCourseVideoID *int64 IntroVideoURL *string } diff --git a/internal/ports/course_management.go b/internal/ports/course_management.go deleted file mode 100644 index 7714e22..0000000 --- a/internal/ports/course_management.go +++ /dev/null @@ -1,216 +0,0 @@ -package ports - -import ( - "Yimaru-Backend/internal/domain" - "context" -) - -type CourseStore interface { - // Course Categories - CreateCourseCategory( - ctx context.Context, - name string, - ) (domain.CourseCategory, error) - GetCourseCategoryByID( - ctx context.Context, - id int64, - ) (domain.CourseCategory, error) - GetAllCourseCategories( - ctx context.Context, - limit int32, - offset int32, - ) ([]domain.CourseCategory, int64, error) - UpdateCourseCategory( - ctx context.Context, - id int64, - name *string, - isActive *bool, - ) error - DeleteCourseCategory( - ctx context.Context, - id int64, - ) error - - // Courses - CreateCourse( - ctx context.Context, - categoryID int64, - title string, - description *string, - thumbnail *string, - introVideoURL *string, - ) (domain.Course, error) - GetCourseByID( - ctx context.Context, - id int64, - ) (domain.Course, error) - GetCoursesByCategory( - ctx context.Context, - categoryID int64, - limit int32, - offset int32, - ) ([]domain.Course, int64, error) - UpdateCourse( - ctx context.Context, - id int64, - title *string, - description *string, - thumbnail *string, - introVideoURL *string, - isActive *bool, - ) error - DeleteCourse( - ctx context.Context, - id int64, - ) error - - // Sub-courses - CreateSubCourse( - ctx context.Context, - courseID int64, - title string, - description *string, - thumbnail *string, - displayOrder *int32, - level string, - subLevel string, - ) (domain.SubCourse, error) - GetSubCourseByID( - ctx context.Context, - id int64, - ) (domain.SubCourse, error) - GetSubCoursesByCourse( - ctx context.Context, - courseID int64, - ) ([]domain.SubCourse, int64, error) - ListSubCoursesByCourse( - ctx context.Context, - courseID int64, - ) ([]domain.SubCourse, error) - ListActiveSubCourses( - ctx context.Context, - ) ([]domain.SubCourse, error) - UpdateSubCourse( - ctx context.Context, - id int64, - title *string, - description *string, - thumbnail *string, - displayOrder *int32, - level *string, - subLevel *string, - isActive *bool, - ) error - DeactivateSubCourse( - ctx context.Context, - id int64, - ) error - DeleteSubCourse( - ctx context.Context, - id int64, - ) (domain.SubCourse, error) - - // Sub-course Videos - 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) - GetSubCourseVideoByID( - ctx context.Context, - id int64, - ) (domain.SubCourseVideo, error) - GetVideosBySubCourse( - ctx context.Context, - subCourseID int64, - ) ([]domain.SubCourseVideo, int64, error) - GetPublishedVideosBySubCourse( - ctx context.Context, - subCourseID int64, - ) ([]domain.SubCourseVideo, error) - GetFirstIncompletePreviousVideo( - ctx context.Context, - userID int64, - videoID int64, - ) (*domain.VideoAccessBlock, error) - MarkVideoCompleted( - ctx context.Context, - userID int64, - videoID int64, - ) error - PublishSubCourseVideo( - ctx context.Context, - videoID int64, - ) error - 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 - ArchiveSubCourseVideo( - ctx context.Context, - id int64, - ) error - DeleteSubCourseVideo( - ctx context.Context, - id int64, - ) error - - // Vimeo integration - UpdateVimeoStatus(ctx context.Context, videoID int64, status string) error - GetVideoByVimeoID(ctx context.Context, vimeoID string) (domain.SubCourseVideo, error) - - // Learning Tree - GetFullLearningTree(ctx context.Context) ([]domain.TreeCourse, error) - - // Learning Path (full nested structure for a course) - GetCourseLearningPath(ctx context.Context, courseID int64) (domain.LearningPath, error) - - // Reorder (drag-and-drop support) - ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error - ReorderCourses(ctx context.Context, ids []int64, positions []int32) error - ReorderSubCourses(ctx context.Context, ids []int64, positions []int32) error - ReorderSubCourseVideos(ctx context.Context, ids []int64, positions []int32) error - ReorderQuestionSets(ctx context.Context, ids []int64, positions []int32) error -} - -type ProgressionStore interface { - // Prerequisites (admin) - AddSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error - RemoveSubCoursePrerequisite(ctx context.Context, subCourseID, prerequisiteSubCourseID int64) error - GetSubCoursePrerequisites(ctx context.Context, subCourseID int64) ([]domain.SubCoursePrerequisite, error) - GetSubCourseDependents(ctx context.Context, prerequisiteSubCourseID int64) ([]domain.SubCourseDependent, error) - CountUnmetPrerequisites(ctx context.Context, subCourseID, userID int64) (int64, error) - DeleteAllPrerequisitesForSubCourse(ctx context.Context, subCourseID int64) error - - // User progress - StartSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) - UpdateSubCourseProgress(ctx context.Context, userID, subCourseID int64, percentage int16) error - CompleteSubCourse(ctx context.Context, userID, subCourseID int64) error - RecalculateSubCourseProgress(ctx context.Context, userID, subCourseID int64) error - GetUserSubCourseProgress(ctx context.Context, userID, subCourseID int64) (domain.UserSubCourseProgress, error) - GetUserCourseProgress(ctx context.Context, userID, courseID int64) ([]domain.UserCourseProgressItem, error) - GetSubCoursesWithProgressByCourse(ctx context.Context, userID, courseID int64) ([]domain.SubCourseWithProgress, error) -} diff --git a/internal/ports/program.go b/internal/ports/program.go new file mode 100644 index 0000000..7877769 --- /dev/null +++ b/internal/ports/program.go @@ -0,0 +1,14 @@ +package ports + +import ( + "Yimaru-Backend/internal/domain" + "context" +) + +type ProgramStore interface { + CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) + GetProgramByID(ctx context.Context, id int64) (domain.Program, error) + ListPrograms(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error) + UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) + DeleteProgram(ctx context.Context, id int64) error +} diff --git a/internal/ports/questions.go b/internal/ports/questions.go index b1ba944..a885374 100644 --- a/internal/ports/questions.go +++ b/internal/ports/questions.go @@ -37,7 +37,6 @@ type QuestionStore interface { GetQuestionSetsByType(ctx context.Context, setType string, limit, offset int32) ([]domain.QuestionSet, int64, error) GetPublishedQuestionSetsByOwner(ctx context.Context, ownerType string, ownerID int64) ([]domain.QuestionSet, error) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet, error) - GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) MarkPracticeCompleted(ctx context.Context, userID int64, questionSetID int64) error UpdateQuestionSet(ctx context.Context, id int64, input domain.CreateQuestionSetInput) error diff --git a/internal/repository/course_catagories.go b/internal/repository/course_catagories.go deleted file mode 100644 index 0e13d9c..0000000 --- a/internal/repository/course_catagories.go +++ /dev/null @@ -1,128 +0,0 @@ -package repository - -import ( - dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -func NewCourseStore(s *Store) *Store { return s } -func NewProgressionStore(s *Store) *Store { return s } - -func (s *Store) CreateCourseCategory( - ctx context.Context, - name string, -) (domain.CourseCategory, error) { - - row, err := s.queries.CreateCourseCategory(ctx, dbgen.CreateCourseCategoryParams{ - Name: name, - Column2: true, - }) - if err != nil { - return domain.CourseCategory{}, err - } - - return domain.CourseCategory{ - ID: row.ID, - Name: row.Name, - IsActive: row.IsActive, - CreatedAt: row.CreatedAt.Time, - }, nil -} - -func (s *Store) GetCourseCategoryByID( - ctx context.Context, - id int64, -) (domain.CourseCategory, error) { - - row, err := s.queries.GetCourseCategoryByID(ctx, id) - if err != nil { - return domain.CourseCategory{}, err - } - - return domain.CourseCategory{ - ID: row.ID, - Name: row.Name, - IsActive: row.IsActive, - CreatedAt: row.CreatedAt.Time, - }, nil -} - -func (s *Store) GetAllCourseCategories( - ctx context.Context, - limit int32, - offset int32, -) ([]domain.CourseCategory, int64, error) { - - rows, err := s.queries.GetAllCourseCategories(ctx, dbgen.GetAllCourseCategoriesParams{ - Limit: pgtype.Int4{Int32: limit}, - Offset: pgtype.Int4{Int32: offset}, - }) - if err != nil { - return nil, 0, err - } - - var ( - categories []domain.CourseCategory - totalCount int64 - ) - - for i, row := range rows { - if i == 0 { - totalCount = row.TotalCount - } - - categories = append(categories, domain.CourseCategory{ - ID: row.ID, - Name: row.Name, - IsActive: row.IsActive, - CreatedAt: row.CreatedAt.Time, - }) - } - - return categories, totalCount, nil -} - -func (s *Store) UpdateCourseCategory( - ctx context.Context, - id int64, - name *string, - isActive *bool, -) error { - - var ( - nameVal string - isActiveVal bool - ) - - if name != nil { - nameVal = *name - } - - if isActive != nil { - isActiveVal = *isActive - } - - return s.queries.UpdateCourseCategory(ctx, dbgen.UpdateCourseCategoryParams{ - Name: nameVal, - IsActive: isActiveVal, - ID: id, - }) -} - -func (s *Store) DeleteCourseCategory( - ctx context.Context, - id int64, -) error { - - return s.queries.DeleteCourseCategory(ctx, id) -} - -func (s *Store) ReorderCourseCategories(ctx context.Context, ids []int64, positions []int32) error { - return s.queries.ReorderCourseCategories(ctx, dbgen.ReorderCourseCategoriesParams{ - Ids: ids, - Positions: positions, - }) -} diff --git a/internal/repository/courses.go b/internal/repository/courses.go deleted file mode 100644 index 23685de..0000000 --- a/internal/repository/courses.go +++ /dev/null @@ -1,173 +0,0 @@ -package repository - -import ( - dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -func (s *Store) CreateCourse( - ctx context.Context, - categoryID int64, - title string, - description *string, - thumbnail *string, - introVideoURL *string, -) (domain.Course, error) { - var descVal, thumbVal, introVideoVal string - if description != nil { - descVal = *description - } - if thumbnail != nil { - thumbVal = *thumbnail - } - if introVideoURL != nil { - introVideoVal = *introVideoURL - } - - row, err := s.queries.CreateCourse(ctx, dbgen.CreateCourseParams{ - CategoryID: categoryID, - Title: title, - Description: pgtype.Text{String: descVal, Valid: description != nil}, - Thumbnail: pgtype.Text{String: thumbVal, Valid: thumbnail != nil}, - IntroVideoUrl: pgtype.Text{String: introVideoVal, Valid: introVideoURL != nil}, - Column6: true, - }) - if err != nil { - return domain.Course{}, err - } - - return mapCourse(row), nil -} - -func (s *Store) GetCourseByID( - ctx context.Context, - id int64, -) (domain.Course, error) { - - row, err := s.queries.GetCourseByID(ctx, id) - if err != nil { - return domain.Course{}, err - } - - return mapCourse(row), nil -} - -func (s *Store) GetCoursesByCategory( - ctx context.Context, - categoryID int64, - limit int32, - offset int32, -) ([]domain.Course, int64, error) { - - rows, err := s.queries.GetCoursesByCategory(ctx, dbgen.GetCoursesByCategoryParams{ - CategoryID: categoryID, - Limit: pgtype.Int4{Int32: limit}, - Offset: pgtype.Int4{Int32: offset}, - }) - if err != nil { - return nil, 0, err - } - - var ( - courses []domain.Course - totalCount int64 - ) - - for i, row := range rows { - if i == 0 { - totalCount = row.TotalCount - } - - courses = append(courses, domain.Course{ - ID: row.ID, - CategoryID: row.CategoryID, - Title: row.Title, - Description: ptrText(row.Description), - Thumbnail: ptrText(row.Thumbnail), - IntroVideoURL: ptrText(row.IntroVideoUrl), - IsActive: row.IsActive, - }) - } - - return courses, totalCount, nil -} - -func (s *Store) UpdateCourse( - ctx context.Context, - id int64, - title *string, - description *string, - thumbnail *string, - introVideoURL *string, - isActive *bool, -) error { - var ( - titleVal string - descriptionVal string - thumbnailVal string - introVideoVal string - isActiveVal bool - ) - - if title != nil { - titleVal = *title - } - if description != nil { - descriptionVal = *description - } - if thumbnail != nil { - thumbnailVal = *thumbnail - } - if introVideoURL != nil { - introVideoVal = *introVideoURL - } - if isActive != nil { - isActiveVal = *isActive - } - - return s.queries.UpdateCourse(ctx, dbgen.UpdateCourseParams{ - Title: titleVal, - Description: pgtype.Text{String: descriptionVal, Valid: description != nil}, - Thumbnail: pgtype.Text{String: thumbnailVal, Valid: thumbnail != nil}, - IntroVideoUrl: pgtype.Text{String: introVideoVal, Valid: introVideoURL != nil}, - IsActive: isActiveVal, - ID: id, - }) -} - -func (s *Store) DeleteCourse( - ctx context.Context, - id int64, -) error { - - return s.queries.DeleteCourse(ctx, id) -} - -func mapCourse(row dbgen.Course) domain.Course { - return domain.Course{ - ID: row.ID, - CategoryID: row.CategoryID, - Title: row.Title, - Description: ptrText(row.Description), - Thumbnail: ptrText(row.Thumbnail), - IntroVideoURL: ptrText(row.IntroVideoUrl), - IsActive: row.IsActive, - } -} - -func (s *Store) ReorderCourses(ctx context.Context, ids []int64, positions []int32) error { - return s.queries.ReorderCourses(ctx, dbgen.ReorderCoursesParams{ - Ids: ids, - Positions: positions, - }) -} - -func ptrText(t pgtype.Text) *string { - if t.Valid { - return &t.String - } - return nil -} diff --git a/internal/repository/programs.go b/internal/repository/programs.go new file mode 100644 index 0000000..e7b7161 --- /dev/null +++ b/internal/repository/programs.go @@ -0,0 +1,112 @@ +package repository + +import ( + "context" + "errors" + + dbgen "Yimaru-Backend/gen/db" + "Yimaru-Backend/internal/domain" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func programToDomain(p dbgen.Program) domain.Program { + out := domain.Program{ + ID: p.ID, + Name: p.Name, + } + out.Description = fromPgText(p.Description) + out.Thumbnail = fromPgText(p.Thumbnail) + out.CreatedAt = p.CreatedAt.Time + if p.UpdatedAt.Valid { + t := p.UpdatedAt.Time + out.UpdatedAt = &t + } + return out +} + +func (s *Store) CreateProgram(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) { + p, err := s.queries.CreateProgram(ctx, dbgen.CreateProgramParams{ + Name: input.Name, + Description: toPgText(input.Description), + Thumbnail: toPgText(input.Thumbnail), + }) + if err != nil { + return domain.Program{}, err + } + return programToDomain(p), nil +} + +func (s *Store) GetProgramByID(ctx context.Context, id int64) (domain.Program, error) { + p, err := s.queries.GetProgramByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Program{}, pgx.ErrNoRows + } + return domain.Program{}, err + } + return programToDomain(p), nil +} + +func (s *Store) ListPrograms(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error) { + rows, err := s.queries.ListPrograms(ctx, dbgen.ListProgramsParams{ + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, 0, err + } + if len(rows) == 0 { + return []domain.Program{}, 0, nil + } + var total int64 + out := make([]domain.Program, 0, len(rows)) + for i, r := range rows { + if i == 0 { + total = r.TotalCount + } + out = append(out, programToDomain(dbgen.Program{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + Thumbnail: r.Thumbnail, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + })) + } + return out, total, nil +} + +func optionalTextUpdate(val *string) pgtype.Text { + if val == nil { + return pgtype.Text{Valid: false} + } + return pgtype.Text{String: *val, Valid: true} +} + +func (s *Store) UpdateProgram(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) { + var nameText pgtype.Text + if input.Name != nil { + nameText = pgtype.Text{String: *input.Name, Valid: true} + } else { + nameText = pgtype.Text{Valid: false} + } + p, err := s.queries.UpdateProgram(ctx, dbgen.UpdateProgramParams{ + ID: id, + Name: nameText, + Description: optionalTextUpdate(input.Description), + Thumbnail: optionalTextUpdate(input.Thumbnail), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Program{}, pgx.ErrNoRows + } + return domain.Program{}, err + } + return programToDomain(p), nil +} + +func (s *Store) DeleteProgram(ctx context.Context, id int64) error { + return s.queries.DeleteProgram(ctx, id) +} diff --git a/internal/repository/questions.go b/internal/repository/questions.go index 1bf7ccf..2c0c7d8 100644 --- a/internal/repository/questions.go +++ b/internal/repository/questions.go @@ -123,7 +123,6 @@ func questionSetToDomain(qs dbgen.QuestionSet) domain.QuestionSet { PassingScore: fromPgInt4(qs.PassingScore), ShuffleQuestions: qs.ShuffleQuestions, Status: qs.Status, - SubCourseVideoID: fromPgInt8(qs.SubCourseVideoID), IntroVideoURL: fromPgText(qs.IntroVideoUrl), CreatedAt: qs.CreatedAt.Time, UpdatedAt: timePtr(qs.UpdatedAt), @@ -542,7 +541,6 @@ func (s *Store) CreateQuestionSet(ctx context.Context, input domain.CreateQuesti PassingScore: toPgInt4(input.PassingScore), Column10: shuffleQuestions, Column11: status, - SubCourseVideoID: toPgInt8(input.SubCourseVideoID), IntroVideoUrl: toPgText(input.IntroVideoURL), }) if err != nil { @@ -604,7 +602,6 @@ func (s *Store) GetQuestionSetsByType(ctx context.Context, setType string, limit PassingScore: fromPgInt4(r.PassingScore), ShuffleQuestions: r.ShuffleQuestions, Status: r.Status, - SubCourseVideoID: fromPgInt8(r.SubCourseVideoID), IntroVideoURL: fromPgText(r.IntroVideoUrl), CreatedAt: r.CreatedAt.Time, UpdatedAt: timePtr(r.UpdatedAt), @@ -637,14 +634,6 @@ func (s *Store) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionSet return questionSetToDomain(qs), nil } -func (s *Store) GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error) { - qs, err := s.queries.GetSubCourseInitialAssessmentSet(ctx, pgtype.Int8{Int64: subCourseID, Valid: true}) - if err != nil { - return domain.QuestionSet{}, err - } - return questionSetToDomain(qs), nil -} - func (s *Store) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) { row, err := s.queries.GetFirstIncompletePreviousPractice(ctx, dbgen.GetFirstIncompletePreviousPracticeParams{ UserID: userID, @@ -692,7 +681,6 @@ func (s *Store) UpdateQuestionSet(ctx context.Context, id int64, input domain.Cr ShuffleQuestions: shuffleQuestions, Status: status, IntroVideoUrl: toPgText(input.IntroVideoURL), - SubCourseVideoID: toPgInt8(input.SubCourseVideoID), }) } diff --git a/internal/services/course_management/service.go b/internal/services/course_management/service.go deleted file mode 100644 index d047a7a..0000000 --- a/internal/services/course_management/service.go +++ /dev/null @@ -1,51 +0,0 @@ -package course_management - -import ( - "Yimaru-Backend/internal/config" - "Yimaru-Backend/internal/ports" - cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" - notificationservice "Yimaru-Backend/internal/services/notification" - vimeoservice "Yimaru-Backend/internal/services/vimeo" -) - -type Service struct { - userStore ports.UserStore - courseStore interface{} - progressionStore interface{} - notificationSvc *notificationservice.Service - vimeoSvc *vimeoservice.Service - cloudConvertSvc *cloudconvertservice.Service - config *config.Config -} - -func NewService( - userStore ports.UserStore, - courseStore interface{}, - progressionStore interface{}, - notificationSvc *notificationservice.Service, - cfg *config.Config, -) *Service { - return &Service{ - userStore: userStore, - courseStore: courseStore, - progressionStore: progressionStore, - notificationSvc: notificationSvc, - config: cfg, - } -} - -func (s *Service) SetVimeoService(vimeoSvc *vimeoservice.Service) { - s.vimeoSvc = vimeoSvc -} - -func (s *Service) HasVimeoService() bool { - return s.vimeoSvc != nil -} - -func (s *Service) SetCloudConvertService(ccSvc *cloudconvertservice.Service) { - s.cloudConvertSvc = ccSvc -} - -func (s *Service) HasCloudConvertService() bool { - return s.cloudConvertSvc != nil -} diff --git a/internal/services/programs/service.go b/internal/services/programs/service.go new file mode 100644 index 0000000..0c73ff9 --- /dev/null +++ b/internal/services/programs/service.go @@ -0,0 +1,69 @@ +package programs + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +var ErrProgramNotFound = errors.New("program not found") + +type Service struct { + store ports.ProgramStore +} + +func NewService(store ports.ProgramStore) *Service { + return &Service{store: store} +} + +func (s *Service) Create(ctx context.Context, input domain.CreateProgramInput) (domain.Program, error) { + return s.store.CreateProgram(ctx, input) +} + +func (s *Service) GetByID(ctx context.Context, id int64) (domain.Program, error) { + p, err := s.store.GetProgramByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Program{}, ErrProgramNotFound + } + return domain.Program{}, err + } + return p, nil +} + +func (s *Service) List(ctx context.Context, limit, offset int32) ([]domain.Program, int64, error) { + if limit <= 0 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + return s.store.ListPrograms(ctx, limit, offset) +} + +func (s *Service) Update(ctx context.Context, id int64, input domain.UpdateProgramInput) (domain.Program, error) { + p, err := s.store.UpdateProgram(ctx, id, input) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Program{}, ErrProgramNotFound + } + return domain.Program{}, err + } + return p, nil +} + +func (s *Service) Delete(ctx context.Context, id int64) error { + if _, err := s.store.GetProgramByID(ctx, id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrProgramNotFound + } + return err + } + return s.store.DeleteProgram(ctx, id) +} diff --git a/internal/services/questions/service.go b/internal/services/questions/service.go index 0d54b80..2ffc996 100644 --- a/internal/services/questions/service.go +++ b/internal/services/questions/service.go @@ -120,10 +120,6 @@ func (s *Service) GetInitialAssessmentSet(ctx context.Context) (domain.QuestionS return s.questionStore.GetInitialAssessmentSet(ctx) } -func (s *Service) GetSubCourseInitialAssessmentSet(ctx context.Context, subCourseID int64) (domain.QuestionSet, error) { - return s.questionStore.GetSubCourseInitialAssessmentSet(ctx, subCourseID) -} - func (s *Service) GetFirstIncompletePreviousPractice(ctx context.Context, userID int64, questionSetID int64) (*domain.PracticeAccessBlock, error) { return s.questionStore.GetFirstIncompletePreviousPractice(ctx, userID, questionSetID) } diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index 337d2f4..93dfd15 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -20,6 +20,13 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "courses.delete", Name: "Delete Course", Description: "Delete a course", GroupName: "Courses"}, {Key: "courses.reorder", Name: "Reorder Courses", Description: "Reorder courses", GroupName: "Courses"}, + // Programs (LMS top-level) + {Key: "programs.create", Name: "Create Program", Description: "Create a program", GroupName: "Programs"}, + {Key: "programs.list", Name: "List Programs", Description: "List programs", GroupName: "Programs"}, + {Key: "programs.get", Name: "Get Program", Description: "Get a program by ID", GroupName: "Programs"}, + {Key: "programs.update", Name: "Update Program", Description: "Update a program", GroupName: "Programs"}, + {Key: "programs.delete", Name: "Delete Program", Description: "Delete a program", GroupName: "Programs"}, + // Course Management - Sub-courses {Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"}, {Key: "subcourses.get", Name: "Get Sub-course", Description: "Get a sub-course by ID", GroupName: "Sub-courses"}, @@ -243,6 +250,9 @@ var DefaultRolePermissions = map[string][]string{ "videos.list_by_subcourse", "videos.list_published", "videos.publish", "videos.update", "videos.delete", "videos.reorder", "learning_tree.get", "practices.reorder", + // Programs + "programs.create", "programs.list", "programs.get", "programs.update", "programs.delete", + // Questions (full access) "questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete", "question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete", @@ -322,6 +332,8 @@ var DefaultRolePermissions = map[string][]string{ "videos.get", "videos.list_by_subcourse", "videos.list_published", "learning_tree.get", + "programs.list", "programs.get", + // Questions (read + attempt) "questions.list", "questions.search", "questions.get", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", @@ -418,6 +430,8 @@ var DefaultRolePermissions = map[string][]string{ "videos.get", "videos.list_by_subcourse", "videos.list_published", "learning_tree.get", + "programs.list", "programs.get", + // Questions (read) "questions.list", "questions.search", "questions.get", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 69fc796..a454cc2 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -8,10 +8,10 @@ import ( "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" - "Yimaru-Backend/internal/services/course_management" minioservice "Yimaru-Backend/internal/services/minio" issuereporting "Yimaru-Backend/internal/services/issue_reporting" notificationservice "Yimaru-Backend/internal/services/notification" + "Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/questions" ratingsservice "Yimaru-Backend/internal/services/ratings" rbacservice "Yimaru-Backend/internal/services/rbac" @@ -35,14 +35,13 @@ import ( "github.com/bytedance/sonic" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/jackc/pgx/v5/pgtype" ) type App struct { - assessmentSvc *assessment.Service - courseSvc *course_management.Service - questionsSvc *questions.Service - subscriptionsSvc *subscriptions.Service + assessmentSvc *assessment.Service + questionsSvc *questions.Service + programSvc *programs.Service + subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService issueReportingSvc *issuereporting.Service vimeoSvc *vimeoservice.Service @@ -64,17 +63,16 @@ type App struct { validator *customvalidator.CustomValidator JwtConfig jwtutil.JwtConfig Logger *slog.Logger - mongoLoggerSvc *zap.Logger - analyticsDB *dbgen.Queries - rbacSvc *rbacservice.Service - stopPurgeWorker context.CancelFunc - stopInactiveSubModuleContentPurge context.CancelFunc + mongoLoggerSvc *zap.Logger + analyticsDB *dbgen.Queries + rbacSvc *rbacservice.Service + stopPurgeWorker context.CancelFunc } func NewApp( assessmentSvc *assessment.Service, - courseSvc *course_management.Service, questionsSvc *questions.Service, + programSvc *programs.Service, subscriptionsSvc *subscriptions.Service, arifpaySvc *arifpay.ArifpayService, issueReportingSvc *issuereporting.Service, @@ -117,8 +115,8 @@ func NewApp( s := &App{ assessmentSvc: assessmentSvc, - courseSvc: courseSvc, questionsSvc: questionsSvc, + programSvc: programSvc, subscriptionsSvc: subscriptionsSvc, arifpaySvc: arifpaySvc, vimeoSvc: vimeoSvc, @@ -154,8 +152,6 @@ func NewApp( func (a *App) Run() error { a.startAccountDeletionPurgeWorker() defer a.stopAccountDeletionPurgeWorker() - a.startInactiveSubModuleContentPurgeWorker() - defer a.stopInactiveSubModuleContentPurgeWorker() return a.fiber.Listen(fmt.Sprintf(":%d", a.port)) } @@ -220,86 +216,3 @@ func (a *App) runAccountDeletionPurgeOnce(ctx context.Context, batchSize int32) a.logger.Info("account deletion purge run completed", "deleted_count", deletedCount, "batch_size", batchSize) } } - -func (a *App) startInactiveSubModuleContentPurgeWorker() { - if a.cfg == nil || !a.cfg.InactiveSubModuleContentPurgeEnabled { - a.logger.Info("inactive submodule content purge worker disabled") - return - } - - interval := a.cfg.InactiveSubModuleContentPurgeInterval - if interval <= 0 { - interval = 24 * time.Hour - } - - retentionDays := a.cfg.InactiveSubModuleContentRetentionDays - if retentionDays < 1 { - retentionDays = 30 - } - retention := time.Duration(retentionDays) * 24 * time.Hour - - ctx, cancel := context.WithCancel(context.Background()) - a.stopInactiveSubModuleContentPurge = cancel - - a.logger.Info( - "starting inactive submodule content purge worker", - "interval", interval.String(), - "retention_days", retentionDays, - ) - - go func() { - a.runInactiveSubModuleContentPurgeOnce(ctx, retention) - - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - a.logger.Info("inactive submodule content purge worker stopped") - return - case <-ticker.C: - a.runInactiveSubModuleContentPurgeOnce(ctx, retention) - } - } - }() -} - -func (a *App) stopInactiveSubModuleContentPurgeWorker() { - if a.stopInactiveSubModuleContentPurge != nil { - a.stopInactiveSubModuleContentPurge() - } -} - -func (a *App) runInactiveSubModuleContentPurgeOnce(ctx context.Context, retention time.Duration) { - cutoff := time.Now().Add(-retention) - cutoffParam := pgtype.Timestamptz{Time: cutoff, Valid: true} - - nLessons, err := a.analyticsDB.PurgeInactiveSubModuleLessonsBefore(ctx, cutoffParam) - if err != nil { - a.logger.Error("purge inactive submodule lessons failed", "error", err) - return - } - - nPractices, err := a.analyticsDB.PurgeInactiveSubModulePracticesBefore(ctx, cutoffParam) - if err != nil { - a.logger.Error("purge inactive submodule practices failed", "error", err) - return - } - - nCapstones, err := a.analyticsDB.PurgeInactiveSubModuleCapstonesBefore(ctx, cutoffParam) - if err != nil { - a.logger.Error("purge inactive submodule capstones failed", "error", err) - return - } - - if nLessons > 0 || nPractices > 0 || nCapstones > 0 { - a.logger.Info( - "inactive submodule content purge run completed", - "lessons_deleted", nLessons, - "practice_question_sets_deleted", nPractices, - "capstone_question_sets_deleted", nCapstones, - "cutoff", cutoff.UTC().Format(time.RFC3339), - ) - } -} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index fadc717..4cea6fa 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -10,13 +10,13 @@ import ( "Yimaru-Backend/internal/services/arifpay" "Yimaru-Backend/internal/services/assessment" "Yimaru-Backend/internal/services/authentication" - course_management "Yimaru-Backend/internal/services/course_management" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" issuereporting "Yimaru-Backend/internal/services/issue_reporting" minioservice "Yimaru-Backend/internal/services/minio" ratingsservice "Yimaru-Backend/internal/services/ratings" rbacservice "Yimaru-Backend/internal/services/rbac" - notificationservice "Yimaru-Backend/internal/services/notification" + notificationservice "Yimaru-Backend/internal/services/notification" + "Yimaru-Backend/internal/services/programs" "Yimaru-Backend/internal/services/questions" "Yimaru-Backend/internal/services/recommendation" "Yimaru-Backend/internal/services/subscriptions" @@ -37,10 +37,10 @@ import ( ) type Handler struct { - assessmentSvc *assessment.Service - courseMgmtSvc *course_management.Service - questionsSvc *questions.Service - subscriptionsSvc *subscriptions.Service + assessmentSvc *assessment.Service + questionsSvc *questions.Service + programSvc *programs.Service + subscriptionsSvc *subscriptions.Service arifpaySvc *arifpay.ArifpayService logger *slog.Logger settingSvc *settings.Service @@ -66,8 +66,8 @@ type Handler struct { func New( assessmentSvc *assessment.Service, - courseMgmtSvc *course_management.Service, questionsSvc *questions.Service, + programSvc *programs.Service, subscriptionsSvc *subscriptions.Service, arifpaySvc *arifpay.ArifpayService, logger *slog.Logger, @@ -93,8 +93,8 @@ func New( ) *Handler { return &Handler{ assessmentSvc: assessmentSvc, - courseMgmtSvc: courseMgmtSvc, questionsSvc: questionsSvc, + programSvc: programSvc, subscriptionsSvc: subscriptionsSvc, arifpaySvc: arifpaySvc, logger: logger, diff --git a/internal/web_server/handlers/hierarchy_handler.go b/internal/web_server/handlers/hierarchy_handler.go deleted file mode 100644 index 794d6f0..0000000 --- a/internal/web_server/handlers/hierarchy_handler.go +++ /dev/null @@ -1,3191 +0,0 @@ -package handlers - -import ( - "context" - dbgen "Yimaru-Backend/gen/db" - "Yimaru-Backend/internal/domain" - "errors" - "strconv" - "strings" - "unicode/utf8" - - "github.com/gofiber/fiber/v2" - "github.com/jackc/pgx/v5" - "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 createCourseCategoryReq struct { - Name string `json:"name"` - IsActive *bool `json:"is_active"` -} - -type createCourseReq struct { - CategoryID int64 `json:"category_id"` - SubCategoryID *int64 `json:"sub_category_id"` - Title string `json:"title"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - IntroVideoURL *string `json:"intro_video_url"` - IsActive *bool `json:"is_active"` -} - -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"` -} - -type updateCourseThumbnailReq struct { - ThumbnailURL string `json:"thumbnail_url"` -} - -type createLevelReq struct { - CourseID int64 `json:"course_id"` - CEFRLevel string `json:"cefr_level"` - Title *string `json:"title"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - DisplayOrder *int32 `json:"display_order"` - IsActive *bool `json:"is_active"` -} - -type updateLevelReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - 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"` - IconURL *string `json:"icon_url"` - DisplayOrder *int32 `json:"display_order"` - IsActive *bool `json:"is_active"` -} - -type updateModuleReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - IconURL *string `json:"icon_url"` - 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"` - Thumbnail *string `json:"thumbnail"` - Tips *string `json:"tips"` - 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 updateSubModuleReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - Tips *string `json:"tips"` - DisplayOrder *int32 `json:"display_order"` - IsActive *bool `json:"is_active"` -} - -type updateSubModuleVideoReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - VideoURL *string `json:"video_url"` -} - -type updatePracticeReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - Persona *string `json:"persona"` - IsActive *bool `json:"is_active"` -} - -type createSubModuleLessonReq struct { - SubModuleID int64 `json:"sub_module_id"` - Title string `json:"title"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - TeachingText *string `json:"teaching_text"` - TeachingImageURL *string `json:"teaching_image_url"` - TeachingAudioURL *string `json:"teaching_audio_url"` - TeachingVideoURL *string `json:"teaching_video_url"` - DisplayOrder *int32 `json:"display_order"` - IsActive *bool `json:"is_active"` -} - -type updateSubModuleLessonReq struct { - SubModuleID *int64 `json:"sub_module_id"` - Title *string `json:"title"` - Description *string `json:"description"` - Thumbnail *string `json:"thumbnail"` - TeachingText *string `json:"teaching_text"` - TeachingImageURL *string `json:"teaching_image_url"` - TeachingAudioURL *string `json:"teaching_audio_url"` - TeachingVideoURL *string `json:"teaching_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"` -} - -type capstoneQuestionItem struct { - QuestionID int64 `json:"question_id"` - DisplayOrder *int32 `json:"display_order"` -} - -type createSubModuleCapstoneReq struct { - SubModuleID int64 `json:"sub_module_id"` - Title string `json:"title"` - Description *string `json:"description"` - Tips *string `json:"tips"` - Thumbnail *string `json:"thumbnail"` - TimeLimitMinutes *int32 `json:"time_limit_minutes"` - PassingScore *int32 `json:"passing_score"` - ShuffleQuestions *bool `json:"shuffle_questions"` - Status *string `json:"status"` - DisplayOrder *int32 `json:"display_order"` - IsActive *bool `json:"is_active"` - Questions []capstoneQuestionItem `json:"questions"` -} - -type updateSubModuleCapstoneReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - Tips *string `json:"tips"` - Thumbnail *string `json:"thumbnail"` - TimeLimitMinutes *int32 `json:"time_limit_minutes"` - PassingScore *int32 `json:"passing_score"` - ShuffleQuestions *bool `json:"shuffle_questions"` - Status *string `json:"status"` - DisplayOrder *int32 `json:"display_order"` - IsActive *bool `json:"is_active"` - Questions []capstoneQuestionItem `json:"questions"` -} - -type createModuleCapstoneReq struct { - ModuleID int64 `json:"module_id"` - Title string `json:"title"` - Description *string `json:"description"` - Tips *string `json:"tips"` - Thumbnail *string `json:"thumbnail"` - TimeLimitMinutes *int32 `json:"time_limit_minutes"` - PassingScore *int32 `json:"passing_score"` - ShuffleQuestions *bool `json:"shuffle_questions"` - Status *string `json:"status"` - DisplayOrder *int32 `json:"display_order"` - IsActive *bool `json:"is_active"` - Questions []capstoneQuestionItem `json:"questions"` -} - -type updateModuleCapstoneReq struct { - Title *string `json:"title"` - Description *string `json:"description"` - Tips *string `json:"tips"` - Thumbnail *string `json:"thumbnail"` - TimeLimitMinutes *int32 `json:"time_limit_minutes"` - PassingScore *int32 `json:"passing_score"` - ShuffleQuestions *bool `json:"shuffle_questions"` - Status *string `json:"status"` - DisplayOrder *int32 `json:"display_order"` - IsActive *bool `json:"is_active"` - Questions []capstoneQuestionItem `json:"questions"` -} - -type legacyHierarchyRow struct { - CategoryID int64 `json:"category_id"` - CategoryName string `json:"category_name"` - SubCategoryID *int64 `json:"sub_category_id"` - SubCategoryName *string `json:"sub_category_name"` - CourseID *int64 `json:"course_id"` - CourseTitle *string `json:"course_title"` -} - -func toText(v *string) pgtype.Text { - if v == nil { - return pgtype.Text{Valid: false} - } - return pgtype.Text{String: *v, Valid: true} -} - -func mergeTextField(current pgtype.Text, req *string) pgtype.Text { - if req == nil { - return current - } - if *req == "" { - return pgtype.Text{Valid: false} - } - return pgtype.Text{String: *req, Valid: true} -} - -func stringPtrFromPgText(t pgtype.Text) *string { - if !t.Valid { - return nil - } - s := t.String - return &s -} - -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 -} - -func textPtr(v pgtype.Text) *string { - if !v.Valid { - return nil - } - s := v.String - return &s -} - -// ListCourseCategories godoc -// @Summary List course categories -// @Description Legacy-compatible endpoint for listing course categories -// @Tags course-management -// @Produce json -// @Success 200 {object} domain.Response -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/categories [get] -func (h *Handler) ListCourseCategories(c *fiber.Ctx) error { - rows, err := h.analyticsDB.GetAllCourseCategories(c.Context(), dbgen.GetAllCourseCategoriesParams{ - Offset: pgtype.Int4{Int32: 0, Valid: true}, - Limit: pgtype.Int4{Int32: 10000, Valid: true}, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load categories", Error: err.Error()}) - } - - total := 0 - if len(rows) > 0 { - total = int(rows[0].TotalCount) - } - - return c.JSON(domain.Response{ - Message: "Categories retrieved successfully", - Data: map[string]interface{}{ - "categories": rows, - "total_count": total, - }, - }) -} - -// ListCoursesByCategory godoc -// @Summary List courses by category -// @Description Legacy-compatible endpoint that returns courses for one category -// @Tags course-management -// @Produce json -// @Param categoryId path int true "Category ID" -// @Param offset query int false "Offset" -// @Param limit query int false "Limit" -// @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) ListCoursesByCategory(c *fiber.Ctx) error { - categoryID, err := strconv.ParseInt(c.Params("categoryId"), 10, 64) - if err != nil || categoryID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid category ID", Error: "categoryId must be a positive integer"}) - } - - offset := int32(c.QueryInt("offset", 0)) - if offset < 0 { - offset = 0 - } - limit := int32(c.QueryInt("limit", 10000)) - if limit <= 0 { - limit = 10000 - } - - rows, err := h.analyticsDB.GetCoursesByCategory(c.Context(), dbgen.GetCoursesByCategoryParams{ - CategoryID: categoryID, - Offset: pgtype.Int4{Int32: offset, Valid: true}, - Limit: pgtype.Int4{Int32: limit, Valid: true}, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load courses", Error: err.Error()}) - } - - total := 0 - if len(rows) > 0 { - total = int(rows[0].TotalCount) - } - - return c.JSON(domain.Response{ - Message: "Courses retrieved successfully", - Data: map[string]interface{}{ - "courses": rows, - "total_count": total, - }, - }) -} - -// ListAllCourses godoc -// @Summary List all courses -// @Description Returns all courses with pagination -// @Tags course-management -// @Produce json -// @Param offset query int false "Offset" -// @Param limit query int false "Limit" -// @Success 200 {object} domain.Response -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/courses [get] -func (h *Handler) ListAllCourses(c *fiber.Ctx) error { - offset := int32(c.QueryInt("offset", 0)) - if offset < 0 { - offset = 0 - } - limit := int32(c.QueryInt("limit", 10000)) - if limit <= 0 { - limit = 10000 - } - - rows, err := h.analyticsDB.GetAllCourses(c.Context(), dbgen.GetAllCoursesParams{ - Offset: pgtype.Int4{Int32: offset, Valid: true}, - Limit: pgtype.Int4{Int32: limit, Valid: true}, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load courses", Error: err.Error()}) - } - - total := 0 - if len(rows) > 0 { - total = int(rows[0].TotalCount) - } - - return c.JSON(domain.Response{ - Message: "Courses retrieved successfully", - Data: map[string]interface{}{ - "courses": rows, - "total_count": total, - }, - }) -} - -// ListHumanLanguageCourses godoc -// @Summary List Human Language courses -// @Description Returns all courses under Human Language category -// @Tags course-management -// @Produce json -// @Param offset query int false "Offset" -// @Param limit query int false "Limit" -// @Success 200 {object} domain.Response -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/human-language/courses [get] -func (h *Handler) ListHumanLanguageCourses(c *fiber.Ctx) error { - offset := int32(c.QueryInt("offset", 0)) - if offset < 0 { - offset = 0 - } - limit := int32(c.QueryInt("limit", 10000)) - if limit <= 0 { - limit = 10000 - } - - rows, err := h.analyticsDB.GetHumanLanguageCourses(c.Context(), dbgen.GetHumanLanguageCoursesParams{ - Offset: pgtype.Int4{Int32: offset, Valid: true}, - Limit: pgtype.Int4{Int32: limit, Valid: true}, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load Human Language courses", Error: err.Error()}) - } - - total := 0 - if len(rows) > 0 { - total = int(rows[0].TotalCount) - } - - return c.JSON(domain.Response{ - Message: "Human Language courses retrieved successfully", - Data: map[string]interface{}{ - "courses": rows, - "total_count": total, - }, - }) -} - -// ListCoursesBySubCategory godoc -// @Summary List courses by sub-category -// @Description Returns courses for one sub-category -// @Tags course-management -// @Produce json -// @Param subCategoryId path int true "Sub-category ID" -// @Param offset query int false "Offset" -// @Param limit query int false "Limit" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-categories/{subCategoryId}/courses [get] -func (h *Handler) ListCoursesBySubCategory(c *fiber.Ctx) error { - subCategoryID, err := strconv.ParseInt(c.Params("subCategoryId"), 10, 64) - if err != nil || subCategoryID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid sub-category ID", Error: "subCategoryId must be a positive integer"}) - } - - offset := int32(c.QueryInt("offset", 0)) - if offset < 0 { - offset = 0 - } - limit := int32(c.QueryInt("limit", 10000)) - if limit <= 0 { - limit = 10000 - } - - rows, err := h.analyticsDB.GetCoursesBySubCategory(c.Context(), dbgen.GetCoursesBySubCategoryParams{ - SubCategoryID: pgtype.Int8{Int64: subCategoryID, Valid: true}, - Offset: pgtype.Int4{Int32: offset, Valid: true}, - Limit: pgtype.Int4{Int32: limit, Valid: true}, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load courses", Error: err.Error()}) - } - - total := 0 - if len(rows) > 0 { - total = int(rows[0].TotalCount) - } - - return c.JSON(domain.Response{ - Message: "Courses retrieved successfully", - Data: map[string]interface{}{ - "courses": rows, - "total_count": total, - }, - }) -} - -// GetCourseByID godoc -// @Summary Get course detail -// @Description Returns one course by ID -// @Tags course-management -// @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} [get] -func (h *Handler) GetCourseByID(c *fiber.Ctx) error { - courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) - if err != nil || courseID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid course ID", - Error: "courseId must be a positive integer", - }) - } - - course, err := h.analyticsDB.GetCourseByID(c.Context(), courseID) - 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: course, - }) -} - -// ListAllLevels godoc -// @Summary List all levels -// @Description Returns all levels with pagination -// @Tags course-management -// @Produce json -// @Param offset query int false "Offset" -// @Param limit query int false "Limit" -// @Success 200 {object} domain.Response -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/levels [get] -func (h *Handler) ListAllLevels(c *fiber.Ctx) error { - offset := int32(c.QueryInt("offset", 0)) - if offset < 0 { - offset = 0 - } - limit := int32(c.QueryInt("limit", 10000)) - if limit <= 0 { - limit = 10000 - } - - rows, err := h.analyticsDB.GetAllLevels(c.Context(), dbgen.GetAllLevelsParams{ - Offset: pgtype.Int4{Int32: offset, Valid: true}, - Limit: pgtype.Int4{Int32: limit, Valid: true}, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load levels", Error: err.Error()}) - } - - total := 0 - if len(rows) > 0 { - total = int(rows[0].TotalCount) - } - - return c.JSON(domain.Response{ - Message: "Levels retrieved successfully", - Data: map[string]interface{}{ - "levels": rows, - "total_count": total, - }, - }) -} - -// ListLevelsByCourse godoc -// @Summary List levels by course -// @Description Returns all active levels for one course -// @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}/levels [get] -func (h *Handler) ListLevelsByCourse(c *fiber.Ctx) error { - courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) - if err != nil || courseID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid course ID", - Error: "courseId must be a positive integer", - }) - } - - rows, err := h.analyticsDB.GetLevelsByCourseID(c.Context(), courseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load levels", Error: err.Error()}) - } - - return c.JSON(domain.Response{ - Message: "Levels retrieved successfully", - Data: map[string]interface{}{ - "levels": rows, - "total_count": len(rows), - }, - }) -} - -// GetLevelByID godoc -// @Summary Get level detail -// @Description Returns one level by ID -// @Tags course-management -// @Produce json -// @Param levelId path int true "Level 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/levels/{levelId} [get] -func (h *Handler) GetLevelByID(c *fiber.Ctx) error { - levelID, err := strconv.ParseInt(c.Params("levelId"), 10, 64) - if err != nil || levelID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid level ID", - Error: "levelId must be a positive integer", - }) - } - - level, err := h.analyticsDB.GetLevelByID(c.Context(), levelID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Level not found", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Level retrieved successfully", - Data: level, - }) -} - -// ListAllModules godoc -// @Summary List all modules -// @Description Returns all modules with pagination -// @Tags course-management -// @Produce json -// @Param offset query int false "Offset" -// @Param limit query int false "Limit" -// @Success 200 {object} domain.Response -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/modules [get] -func (h *Handler) ListAllModules(c *fiber.Ctx) error { - offset := int32(c.QueryInt("offset", 0)) - if offset < 0 { - offset = 0 - } - limit := int32(c.QueryInt("limit", 10000)) - if limit <= 0 { - limit = 10000 - } - - rows, err := h.analyticsDB.GetAllModules(c.Context(), dbgen.GetAllModulesParams{ - Offset: pgtype.Int4{Int32: offset, Valid: true}, - Limit: pgtype.Int4{Int32: limit, Valid: true}, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load modules", Error: err.Error()}) - } - - total := 0 - if len(rows) > 0 { - total = int(rows[0].TotalCount) - } - - return c.JSON(domain.Response{ - Message: "Modules retrieved successfully", - Data: map[string]interface{}{ - "modules": rows, - "total_count": total, - }, - }) -} - -// ListModulesByLevel godoc -// @Summary List modules by level -// @Description Returns all active modules for one level -// @Tags course-management -// @Produce json -// @Param levelId path int true "Level ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/levels/{levelId}/modules [get] -func (h *Handler) ListModulesByLevel(c *fiber.Ctx) error { - levelID, err := strconv.ParseInt(c.Params("levelId"), 10, 64) - if err != nil || levelID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid level ID", - Error: "levelId must be a positive integer", - }) - } - - rows, err := h.analyticsDB.GetModulesByLevelID(c.Context(), levelID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load modules", Error: err.Error()}) - } - - return c.JSON(domain.Response{ - Message: "Modules retrieved successfully", - Data: map[string]interface{}{ - "modules": rows, - "total_count": len(rows), - }, - }) -} - -// GetModuleByID godoc -// @Summary Get module detail -// @Description Returns one module by ID -// @Tags course-management -// @Produce json -// @Param moduleId path int true "Module 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/modules/{moduleId} [get] -func (h *Handler) GetModuleByID(c *fiber.Ctx) error { - moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64) - if err != nil || moduleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid module ID", - Error: "moduleId must be a positive integer", - }) - } - - mod, err := h.analyticsDB.GetModuleByID(c.Context(), moduleID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Module not found", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Module retrieved successfully", - Data: mod, - }) -} - -// ListAllSubModules godoc -// @Summary List all sub-modules -// @Description Returns all sub-modules with pagination -// @Tags course-management -// @Produce json -// @Param offset query int false "Offset" -// @Param limit query int false "Limit" -// @Success 200 {object} domain.Response -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-modules [get] -func (h *Handler) ListAllSubModules(c *fiber.Ctx) error { - offset := int32(c.QueryInt("offset", 0)) - if offset < 0 { - offset = 0 - } - limit := int32(c.QueryInt("limit", 10000)) - if limit <= 0 { - limit = 10000 - } - - rows, err := h.analyticsDB.GetAllSubModules(c.Context(), dbgen.GetAllSubModulesParams{ - Offset: pgtype.Int4{Int32: offset, Valid: true}, - Limit: pgtype.Int4{Int32: limit, Valid: true}, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load sub-modules", Error: err.Error()}) - } - - total := 0 - if len(rows) > 0 { - total = int(rows[0].TotalCount) - } - - return c.JSON(domain.Response{ - Message: "Sub-modules retrieved successfully", - Data: map[string]interface{}{ - "sub_modules": rows, - "total_count": total, - }, - }) -} - -// ListSubModulesByModule godoc -// @Summary List sub-modules by module -// @Description Returns all active sub-modules for one module -// @Tags course-management -// @Produce json -// @Param moduleId path int true "Module ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/modules/{moduleId}/sub-modules [get] -func (h *Handler) ListSubModulesByModule(c *fiber.Ctx) error { - moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64) - if err != nil || moduleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid module ID", - Error: "moduleId must be a positive integer", - }) - } - - rows, err := h.analyticsDB.GetSubModulesByModuleID(c.Context(), moduleID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load sub-modules", Error: err.Error()}) - } - - return c.JSON(domain.Response{ - Message: "Sub-modules retrieved successfully", - Data: map[string]interface{}{ - "sub_modules": rows, - "total_count": len(rows), - }, - }) -} - -// GetSubModuleByID godoc -// @Summary Get sub-module detail -// @Description Returns one sub-module by ID -// @Tags course-management -// @Produce json -// @Param subModuleId path int true "Sub-module 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-modules/{subModuleId} [get] -func (h *Handler) GetSubModuleByID(c *fiber.Ctx) error { - subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64) - if err != nil || subModuleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-module ID", - Error: "subModuleId must be a positive integer", - }) - } - - subModule, err := h.analyticsDB.GetSubModuleByID(c.Context(), subModuleID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Sub-module not found", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Sub-module retrieved successfully", - Data: subModule, - }) -} - -// ListCourseSubCategories godoc -// @Summary List course sub-categories -// @Description Returns all active course sub-categories -// @Tags course-management -// @Produce json -// @Param offset query int false "Offset" -// @Param limit query int false "Limit" -// @Success 200 {object} domain.Response -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-categories [get] -func (h *Handler) ListCourseSubCategories(c *fiber.Ctx) error { - offset := int32(c.QueryInt("offset", 0)) - if offset < 0 { - offset = 0 - } - limit := int32(c.QueryInt("limit", 10000)) - if limit <= 0 { - limit = 10000 - } - - rows, err := h.analyticsDB.GetCourseSubCategories(c.Context(), dbgen.GetCourseSubCategoriesParams{ - Offset: pgtype.Int4{Int32: offset, Valid: true}, - Limit: pgtype.Int4{Int32: limit, Valid: true}, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load sub-categories", Error: err.Error()}) - } - - total := 0 - if len(rows) > 0 { - total = int(rows[0].TotalCount) - } - - return c.JSON(domain.Response{ - Message: "Sub-categories retrieved successfully", - Data: map[string]interface{}{ - "sub_categories": rows, - "total_count": total, - }, - }) -} - -// ListCourseSubCategoriesByCategory godoc -// @Summary List sub-categories for a course category -// @Description Returns active sub-categories for the given category ID -// @Tags course-management -// @Produce json -// @Param categoryId path int true "Category ID" -// @Param offset query int false "Offset" -// @Param limit query int false "Limit" -// @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/{categoryId}/sub-categories [get] -func (h *Handler) ListCourseSubCategoriesByCategory(c *fiber.Ctx) error { - categoryID, err := strconv.ParseInt(c.Params("categoryId"), 10, 64) - if err != nil || categoryID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid category ID", - Error: "categoryId must be a positive integer", - }) - } - if _, err := h.analyticsDB.GetCourseCategoryByID(c.Context(), categoryID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Category not found", - Error: err.Error(), - }) - } - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to load category", - Error: err.Error(), - }) - } - - offset := int32(c.QueryInt("offset", 0)) - if offset < 0 { - offset = 0 - } - limit := int32(c.QueryInt("limit", 10000)) - if limit <= 0 { - limit = 10000 - } - - rows, err := h.analyticsDB.GetCourseSubCategoriesByCategoryID(c.Context(), dbgen.GetCourseSubCategoriesByCategoryIDParams{ - CategoryID: categoryID, - Offset: pgtype.Int4{Int32: offset, Valid: true}, - Limit: pgtype.Int4{Int32: limit, Valid: true}, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to load sub-categories", - Error: err.Error(), - }) - } - - total := 0 - if len(rows) > 0 { - total = int(rows[0].TotalCount) - } - - return c.JSON(domain.Response{ - Message: "Sub-categories retrieved successfully", - Data: map[string]interface{}{ - "sub_categories": rows, - "total_count": total, - }, - }) -} - -// ListHumanLanguageCourseSubCategories godoc -// @Summary List Human Language sub-categories -// @Description Returns active sub-categories under Human Language category -// @Tags course-management -// @Produce json -// @Param offset query int false "Offset" -// @Param limit query int false "Limit" -// @Success 200 {object} domain.Response -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/human-language/sub-categories [get] -func (h *Handler) ListHumanLanguageCourseSubCategories(c *fiber.Ctx) error { - offset := int32(c.QueryInt("offset", 0)) - if offset < 0 { - offset = 0 - } - limit := int32(c.QueryInt("limit", 10000)) - if limit <= 0 { - limit = 10000 - } - - rows, err := h.analyticsDB.GetHumanLanguageCourseSubCategories(c.Context(), dbgen.GetHumanLanguageCourseSubCategoriesParams{ - Offset: pgtype.Int4{Int32: offset, Valid: true}, - Limit: pgtype.Int4{Int32: limit, Valid: true}, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load Human Language sub-categories", Error: err.Error()}) - } - - total := 0 - if len(rows) > 0 { - total = int(rows[0].TotalCount) - } - - return c.JSON(domain.Response{ - Message: "Human Language sub-categories retrieved successfully", - Data: map[string]interface{}{ - "sub_categories": rows, - "total_count": total, - }, - }) -} - -// CreateCourseCategory godoc -// @Summary Create course category -// @Description Legacy-compatible endpoint for creating a course category -// @Tags course-management -// @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()}) - } - if strings.TrimSpace(req.Name) == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "name is required"}) - } - - created, err := h.analyticsDB.CreateCourseCategory(c.Context(), dbgen.CreateCourseCategoryParams{ - Name: req.Name, - Column2: boolOrNil(req.IsActive), - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create category", Error: err.Error()}) - } - - return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Course category created", Data: created}) -} - -func (h *Handler) DeleteCourseCategory(c *fiber.Ctx) error { - categoryID, err := strconv.ParseInt(c.Params("categoryId"), 10, 64) - if err != nil || categoryID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid category ID", Error: "categoryId must be a positive integer"}) - } - - if err := h.analyticsDB.DeleteCourseCategoryCompat(c.Context(), categoryID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete category", Error: err.Error()}) - } - - return c.JSON(domain.Response{Message: "Course category deleted"}) -} - -// CreateCourse godoc -// @Summary Create course -// @Description Legacy-compatible endpoint for creating a course -// @Tags course-management -// @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()}) - } - if req.CategoryID <= 0 || strings.TrimSpace(req.Title) == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "category_id and title are required"}) - } - - isActive := true - if req.IsActive != nil { - isActive = *req.IsActive - } - description := "" - if req.Description != nil { - description = *req.Description - } - thumbnail := "" - if req.Thumbnail != nil { - thumbnail = *req.Thumbnail - } - introVideoURL := "" - if req.IntroVideoURL != nil { - introVideoURL = *req.IntroVideoURL - } - - created, err := h.analyticsDB.CreateCourseCompat( - c.Context(), - req.CategoryID, - req.SubCategoryID, - req.Title, - description, - thumbnail, - introVideoURL, - isActive, - ) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create course", Error: err.Error()}) - } - - return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Course created", Data: created}) -} - -// UpdateCourse godoc -// @Summary Update course -// @Description Legacy-compatible endpoint for updating a course -// @Tags course-management -// @Accept json -// @Produce json -// @Param courseId 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/{courseId} [put] -func (h *Handler) UpdateCourse(c *fiber.Ctx) error { - courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) - if err != nil || courseID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: "courseId must be a positive integer"}) - } - - 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()}) - } - - existing, err := h.analyticsDB.GetCourseByID(c.Context(), courseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course", Error: err.Error()}) - } - - title := existing.Title - if req.Title != nil { - title = *req.Title - } - description := existing.Description - if req.Description != nil { - description = toText(req.Description) - } - thumbnail := existing.Thumbnail - if req.Thumbnail != nil { - thumbnail = toText(req.Thumbnail) - } - introVideo := existing.IntroVideoUrl - if req.IntroVideoURL != nil { - introVideo = toText(req.IntroVideoURL) - } - isActive := existing.IsActive - if req.IsActive != nil { - isActive = *req.IsActive - } - - if err := h.analyticsDB.UpdateCourse(c.Context(), dbgen.UpdateCourseParams{ - Title: title, - Description: description, - Thumbnail: thumbnail, - IntroVideoUrl: introVideo, - IsActive: isActive, - ID: courseID, - }); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update course", Error: err.Error()}) - } - - updated, err := h.analyticsDB.GetCourseByID(c.Context(), courseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Course updated but failed to fetch latest record", Error: err.Error()}) - } - - return c.JSON(domain.Response{Message: "Course updated", Data: updated}) -} - -// DeleteCourse godoc -// @Summary Delete course -// @Description Legacy-compatible endpoint for deleting a course -// @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} [delete] -func (h *Handler) DeleteCourse(c *fiber.Ctx) error { - courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) - if err != nil || courseID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: "courseId must be a positive integer"}) - } - - if err := h.analyticsDB.DeleteCourse(c.Context(), courseID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete course", Error: err.Error()}) - } - - return c.JSON(domain.Response{Message: "Course deleted"}) -} - -// UpdateCourseThumbnail godoc -// @Summary Update course thumbnail -// @Description Legacy-compatible endpoint for updating course thumbnail -// @Tags course-management -// @Accept json -// @Produce json -// @Param courseId path int true "Course ID" -// @Param body body updateCourseThumbnailReq true "Update course thumbnail payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/courses/{courseId}/thumbnail [post] -func (h *Handler) UpdateCourseThumbnail(c *fiber.Ctx) error { - courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) - if err != nil || courseID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: "courseId must be a positive integer"}) - } - - var req updateCourseThumbnailReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) - } - - existing, err := h.analyticsDB.GetCourseByID(c.Context(), courseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course", Error: err.Error()}) - } - thumb := req.ThumbnailURL - if strings.TrimSpace(thumb) == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "thumbnail_url is required"}) - } - - if err := h.analyticsDB.UpdateCourse(c.Context(), dbgen.UpdateCourseParams{ - Title: existing.Title, - Description: existing.Description, - Thumbnail: pgtype.Text{String: thumb, Valid: true}, - IntroVideoUrl: existing.IntroVideoUrl, - IsActive: existing.IsActive, - ID: courseID, - }); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update course thumbnail", Error: err.Error()}) - } - - updated, err := h.analyticsDB.GetCourseByID(c.Context(), courseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Thumbnail updated but failed to fetch latest record", Error: err.Error()}) - } - - return c.JSON(domain.Response{Message: "Course thumbnail updated", Data: updated}) -} - -// 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 { - if isMissingCourseSubCategoryTableErr(err) { - legacyRows, legacyErr := h.buildLegacyHierarchyRows(c) - if legacyErr != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load hierarchy", Error: legacyErr.Error()}) - } - return c.JSON(domain.Response{Message: "Unified hierarchy retrieved successfully", Data: legacyRows}) - } - 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 { - if isMissingCourseSubCategoryTableErr(err) { - course, getCourseErr := h.analyticsDB.GetCourseByID(c.Context(), courseID) - if getCourseErr != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course hierarchy", Error: getCourseErr.Error()}) - } - return c.JSON(domain.Response{ - Message: "Course hierarchy retrieved successfully", - Data: []map[string]interface{}{ - { - "course_id": course.ID, - "course_title": course.Title, - "level_id": nil, - "cefr_level": nil, - "level_title": nil, - "level_description": nil, - "level_thumbnail": nil, - "module_id": nil, - "module_title": nil, - "module_icon_url": nil, - "sub_module_id": nil, - "sub_module_title": nil, - "sub_module_description": nil, - "sub_module_thumbnail": nil, - "sub_module_tips": nil, - "sub_module_display_order": 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}) -} - -// CourseLearningPath godoc -// @Summary Get course learning path -// @Description Legacy-compatible endpoint for course learning path -// @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}/learning-path [get] -func (h *Handler) CourseLearningPath(c *fiber.Ctx) error { - courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64) - if err != nil || courseID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: "courseId must be a positive integer"}) - } - - course, err := h.analyticsDB.GetCourseByID(c.Context(), courseID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course", Error: err.Error()}) - } - category, err := h.analyticsDB.GetCourseCategoryByID(c.Context(), course.CategoryID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course category", 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 learning path", Error: err.Error()}) - } - - subCourseByID := map[int64]*domain.LearningPathSubCourse{} - subCourseOrder := make([]int64, 0) - for _, row := range rows { - if !row.SubModuleID.Valid { - continue - } - subModuleID := row.SubModuleID.Int64 - if _, exists := subCourseByID[subModuleID]; exists { - continue - } - title := "" - if row.SubModuleTitle.Valid { - title = row.SubModuleTitle.String - } - level := "" - if row.LevelTitle.Valid && strings.TrimSpace(row.LevelTitle.String) != "" { - level = strings.TrimSpace(row.LevelTitle.String) - } else if row.CefrLevel.Valid { - level = row.CefrLevel.String - } - displayOrder := int32(len(subCourseOrder)) - if row.SubModuleDisplayOrder.Valid { - displayOrder = row.SubModuleDisplayOrder.Int32 - } - subCourseByID[subModuleID] = &domain.LearningPathSubCourse{ - ID: subModuleID, - Title: title, - Description: textPtr(row.SubModuleDescription), - Thumbnail: textPtr(row.SubModuleThumbnail), - Tips: textPtr(row.SubModuleTips), - DisplayOrder: displayOrder, - Level: level, - SubLevel: level, - PrerequisiteCount: 0, - Prerequisites: []domain.LearningPathPrerequisite{}, - Videos: []domain.LearningPathVideo{}, - Practices: []domain.LearningPathPractice{}, - } - subCourseOrder = append(subCourseOrder, subModuleID) - } - - for _, subModuleID := range subCourseOrder { - node := subCourseByID[subModuleID] - videos, videoErr := h.analyticsDB.GetSubModuleVideos(c.Context(), subModuleID) - if videoErr != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load sub-module videos", Error: videoErr.Error()}) - } - for _, v := range videos { - node.Videos = append(node.Videos, domain.LearningPathVideo{ - ID: v.ID, - Title: v.Title, - Description: textPtr(v.Description), - VideoURL: v.VideoUrl, - Duration: int32(v.Duration.Int32), - Resolution: textPtr(v.Resolution), - DisplayOrder: v.DisplayOrder, - VimeoID: textPtr(v.VimeoID), - VimeoEmbedURL: textPtr(v.VimeoEmbedUrl), - VideoHostProvider: textPtr(v.VideoHostProvider), - }) - } - node.VideoCount = int64(len(node.Videos)) - - practices, practiceErr := h.analyticsDB.GetSubModulePractices(c.Context(), subModuleID) - if practiceErr != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load sub-module practices", Error: practiceErr.Error()}) - } - for _, p := range practices { - node.Practices = append(node.Practices, domain.LearningPathPractice{ - ID: p.ID, - Title: p.Title, - Description: textPtr(p.Description), - Status: p.Status, - IntroVideoURL: textPtr(p.IntroVideoUrl), - QuestionCount: p.QuestionCount, - }) - } - node.PracticeCount = int64(len(node.Practices)) - } - - subCourses := make([]domain.LearningPathSubCourse, 0, len(subCourseOrder)) - for _, id := range subCourseOrder { - subCourses = append(subCourses, *subCourseByID[id]) - } - - path := domain.LearningPath{ - CourseID: course.ID, - CourseTitle: course.Title, - Description: textPtr(course.Description), - Thumbnail: textPtr(course.Thumbnail), - IntroVideoURL: textPtr(course.IntroVideoUrl), - CategoryID: category.ID, - CategoryName: category.Name, - SubCourses: subCourses, - } - - return c.JSON(domain.Response{Message: "Course learning path retrieved successfully", Data: path}) -} - -func isMissingCourseSubCategoryTableErr(err error) bool { - if err == nil { - return false - } - return strings.Contains(strings.ToLower(err.Error()), "relation \"course_sub_categories\" does not exist") -} - -func (h *Handler) buildLegacyHierarchyRows(c *fiber.Ctx) ([]legacyHierarchyRow, error) { - categories, err := h.analyticsDB.GetAllCourseCategories(c.Context(), dbgen.GetAllCourseCategoriesParams{ - Offset: pgtype.Int4{Int32: 0, Valid: true}, - Limit: pgtype.Int4{Int32: 10000, Valid: true}, - }) - if err != nil { - return nil, err - } - - out := make([]legacyHierarchyRow, 0, len(categories)) - for _, cat := range categories { - courses, courseErr := h.analyticsDB.GetCoursesByCategory(c.Context(), dbgen.GetCoursesByCategoryParams{ - CategoryID: cat.ID, - Offset: pgtype.Int4{Int32: 0, Valid: true}, - Limit: pgtype.Int4{Int32: 10000, Valid: true}, - }) - if courseErr != nil { - return nil, courseErr - } - - if len(courses) == 0 { - out = append(out, legacyHierarchyRow{ - CategoryID: cat.ID, - CategoryName: cat.Name, - SubCategoryID: nil, - SubCategoryName: nil, - CourseID: nil, - CourseTitle: nil, - }) - continue - } - - for _, course := range courses { - courseID := course.ID - courseTitle := course.Title - out = append(out, legacyHierarchyRow{ - CategoryID: cat.ID, - CategoryName: cat.Name, - SubCategoryID: nil, - SubCategoryName: nil, - CourseID: &courseID, - CourseTitle: &courseTitle, - }) - } - } - - return out, nil -} - -// 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}) -} - -func (h *Handler) DeleteCourseSubCategory(c *fiber.Ctx) error { - subCategoryID, err := strconv.ParseInt(c.Params("subCategoryId"), 10, 64) - if err != nil || subCategoryID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid sub-category ID", Error: "subCategoryId must be a positive integer"}) - } - - if err := h.analyticsDB.DeleteCourseSubCategoryCompat(c.Context(), subCategoryID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete sub-category", Error: err.Error()}) - } - - return c.JSON(domain.Response{Message: "Course sub-category deleted"}) -} - -// CreateLevel godoc -// @Summary Create level -// @Description Creates a level under a course. cefr_level is a short level code or label (1–64 characters), unique per course; optional title defaults to that value; optional description and thumbnail -// @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()}) - } - cefr := strings.TrimSpace(req.CEFRLevel) - if req.CourseID <= 0 || cefr == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "course_id and cefr_level are required"}) - } - if strings.Contains(cefr, "\x00") { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "cefr_level must not contain NUL characters"}) - } - const maxCefrLevelRunes = 64 - if utf8.RuneCountInString(cefr) > maxCefrLevelRunes { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "cefr_level must be at most 64 characters"}) - } - title := cefr - if req.Title != nil { - if t := strings.TrimSpace(*req.Title); t != "" { - title = t - } - } - created, err := h.analyticsDB.CreateLevel(c.Context(), dbgen.CreateLevelParams{ - CourseID: req.CourseID, - CefrLevel: cefr, - Title: title, - Description: toText(req.Description), - Thumbnail: toText(req.Thumbnail), - Column6: intOrNil(req.DisplayOrder), - Column7: 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}) -} - -// UpdateLevel godoc -// @Summary Update level -// @Description Updates level title, description, thumbnail, display order, and active flag -// @Tags course-management -// @Accept json -// @Produce json -// @Param levelId path int true "Level ID" -// @Param body body updateLevelReq true "Update level payload" -// @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/levels/{levelId} [put] -func (h *Handler) UpdateLevel(c *fiber.Ctx) error { - levelID, err := strconv.ParseInt(c.Params("levelId"), 10, 64) - if err != nil || levelID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid level ID", Error: "levelId must be a positive integer"}) - } - var req updateLevelReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) - } - - current, err := h.analyticsDB.GetLevelByID(c.Context(), levelID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Level not found", Error: err.Error()}) - } - - targetTitle := current.Title - if req.Title != nil { - t := strings.TrimSpace(*req.Title) - if t == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"}) - } - targetTitle = t - } - - targetDescription := mergeTextField(current.Description, req.Description) - targetThumbnail := mergeTextField(current.Thumbnail, req.Thumbnail) - - targetDisplayOrder := current.DisplayOrder - if req.DisplayOrder != nil { - targetDisplayOrder = *req.DisplayOrder - } - - targetIsActive := current.IsActive - if req.IsActive != nil { - targetIsActive = *req.IsActive - } - - updated, err := h.analyticsDB.UpdateLevel(c.Context(), dbgen.UpdateLevelParams{ - Title: targetTitle, - Description: targetDescription, - Thumbnail: targetThumbnail, - DisplayOrder: targetDisplayOrder, - IsActive: targetIsActive, - ID: levelID, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update level", Error: err.Error()}) - } - - return c.JSON(domain.Response{Message: "Level updated", Data: updated}) -} - -// CreateModule godoc -// @Summary Create module -// @Description Creates a module under a level; optional icon_url stores a module icon image URL -// @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), - IconUrl: toText(req.IconURL), - Column5: intOrNil(req.DisplayOrder), - Column6: 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}) -} - -// UpdateModule godoc -// @Summary Update module -// @Description Updates module title, description, icon URL, display order, and active flag -// @Tags course-management -// @Accept json -// @Produce json -// @Param moduleId path int true "Module ID" -// @Param body body updateModuleReq true "Update module payload" -// @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/modules/{moduleId} [put] -func (h *Handler) UpdateModule(c *fiber.Ctx) error { - moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64) - if err != nil || moduleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid module ID", Error: "moduleId must be a positive integer"}) - } - var req updateModuleReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) - } - - current, err := h.analyticsDB.GetModuleByID(c.Context(), moduleID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Module not found", Error: err.Error()}) - } - - targetTitle := current.Title - if req.Title != nil { - t := strings.TrimSpace(*req.Title) - if t == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"}) - } - targetTitle = t - } - targetDesc := mergeTextField(current.Description, req.Description) - targetIcon := mergeTextField(current.IconUrl, req.IconURL) - targetOrder := current.DisplayOrder - if req.DisplayOrder != nil { - targetOrder = *req.DisplayOrder - } - targetActive := current.IsActive - if req.IsActive != nil { - targetActive = *req.IsActive - } - - updated, err := h.analyticsDB.UpdateModule(c.Context(), dbgen.UpdateModuleParams{ - Title: targetTitle, - Description: targetDesc, - IconUrl: targetIcon, - DisplayOrder: targetOrder, - IsActive: targetActive, - ID: moduleID, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update module", Error: err.Error()}) - } - return c.JSON(domain.Response{Message: "Module updated", Data: updated}) -} - -// CreateSubModule godoc -// @Summary Create sub-module -// @Description Creates a sub-module under a module; optional thumbnail (image URL) and tips text -// @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), - Thumbnail: toText(req.Thumbnail), - Tips: toText(req.Tips), - Column6: intOrNil(req.DisplayOrder), - Column7: 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}) -} - -// CreateSubModuleLesson godoc -// @Summary Create lesson under sub-module -// @Description Creates a sub-module lesson with teaching content (text, image, audio, video URLs) and optional thumbnail -// @Tags course-management -// @Accept json -// @Produce json -// @Param body body createSubModuleLessonReq true "Create 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) CreateSubModuleLesson(c *fiber.Ctx) error { - var req createSubModuleLessonReq - 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) == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id and title are required"}) - } - created, err := h.analyticsDB.CreateSubModuleLesson(c.Context(), dbgen.CreateSubModuleLessonParams{ - SubModuleID: req.SubModuleID, - Title: strings.TrimSpace(req.Title), - Description: toText(req.Description), - Thumbnail: toText(req.Thumbnail), - TeachingText: toText(req.TeachingText), - TeachingImageUrl: toText(req.TeachingImageURL), - TeachingAudioUrl: toText(req.TeachingAudioURL), - TeachingVideoUrl: toText(req.TeachingVideoURL), - Column9: intOrNil(req.DisplayOrder), - Column10: boolOrNil(req.IsActive), - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create lesson", Error: err.Error()}) - } - return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Lesson created", Data: created}) -} - -// GetSubModuleLessons godoc -// @Summary Get lessons under sub-module -// @Description Returns lessons for a sub-module. By default only active lessons; pass include_inactive=true to include inactive rows (e.g. admin / CMS). -// @Tags course-management -// @Accept json -// @Produce json -// @Param subModuleId path int true "Sub-module ID" -// @Param include_inactive query bool false "Include inactive lessons" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-modules/{subModuleId}/lessons [get] -func (h *Handler) GetSubModuleLessons(c *fiber.Ctx) error { - subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64) - if err != nil || subModuleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-module ID", - Error: "subModuleId must be a valid positive integer", - }) - } - - var lessons []dbgen.SubModuleLesson - if c.QueryBool("include_inactive", false) { - lessons, err = h.analyticsDB.GetSubModuleLessonsAll(c.Context(), subModuleID) - } else { - lessons, err = h.analyticsDB.GetSubModuleLessons(c.Context(), subModuleID) - } - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to get sub-module lessons", - Error: err.Error(), - }) - } - - return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Sub-module lessons retrieved successfully", - Data: lessons, - }) -} - -// GetSubModuleLessonByID godoc -// @Summary Get lesson detail -// @Description Returns one lesson detail by lesson ID (active or inactive) -// @Tags course-management -// @Accept json -// @Produce json -// @Param lessonId path int true "Lesson 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-module-lessons/{lessonId} [get] -func (h *Handler) GetSubModuleLessonByID(c *fiber.Ctx) error { - lessonID, err := strconv.ParseInt(c.Params("lessonId"), 10, 64) - if err != nil || lessonID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid lesson ID", - Error: "lessonId must be a valid positive integer", - }) - } - - lesson, err := h.analyticsDB.GetSubModuleLessonByID(c.Context(), lessonID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Lesson not found", - Error: err.Error(), - }) - } - - return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Lesson detail retrieved successfully", - Data: lesson, - }) -} - -// UpdateSubModuleLesson godoc -// @Summary Update lesson detail -// @Description Updates lesson teaching content, thumbnail, ordering, and active flag -// @Tags course-management -// @Accept json -// @Produce json -// @Param lessonId path int true "Lesson ID" -// @Param body body updateSubModuleLessonReq true "Update lesson payload" -// @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-module-lessons/{lessonId} [put] -func (h *Handler) UpdateSubModuleLesson(c *fiber.Ctx) error { - lessonID, err := strconv.ParseInt(c.Params("lessonId"), 10, 64) - if err != nil || lessonID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid lesson ID", - Error: "lessonId must be a valid positive integer", - }) - } - - var req updateSubModuleLessonReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid request body", - Error: err.Error(), - }) - } - - currentLesson, err := h.analyticsDB.GetSubModuleLessonByID(c.Context(), lessonID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Lesson not found", - Error: err.Error(), - }) - } - - targetSubModuleID := currentLesson.SubModuleID - if req.SubModuleID != nil { - if *req.SubModuleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id must be a positive integer"}) - } - targetSubModuleID = *req.SubModuleID - } - - targetTitle := currentLesson.Title - if req.Title != nil { - t := strings.TrimSpace(*req.Title) - if t == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"}) - } - targetTitle = t - } - - targetDescription := mergeTextField(currentLesson.Description, req.Description) - targetThumbnail := mergeTextField(currentLesson.Thumbnail, req.Thumbnail) - targetTeachingText := mergeTextField(currentLesson.TeachingText, req.TeachingText) - targetTeachingImage := mergeTextField(currentLesson.TeachingImageUrl, req.TeachingImageURL) - targetTeachingAudio := mergeTextField(currentLesson.TeachingAudioUrl, req.TeachingAudioURL) - targetTeachingVideo := mergeTextField(currentLesson.TeachingVideoUrl, req.TeachingVideoURL) - - targetDisplayOrder := currentLesson.DisplayOrder - if req.DisplayOrder != nil { - targetDisplayOrder = *req.DisplayOrder - } - - targetIsActive := currentLesson.IsActive - if req.IsActive != nil { - targetIsActive = *req.IsActive - } - - updatedLesson, err := h.analyticsDB.UpdateSubModuleLesson(c.Context(), dbgen.UpdateSubModuleLessonParams{ - SubModuleID: targetSubModuleID, - Title: targetTitle, - Description: targetDescription, - Thumbnail: targetThumbnail, - TeachingText: targetTeachingText, - TeachingImageUrl: targetTeachingImage, - TeachingAudioUrl: targetTeachingAudio, - TeachingVideoUrl: targetTeachingVideo, - DisplayOrder: targetDisplayOrder, - IsActive: targetIsActive, - ID: lessonID, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to update lesson", - Error: err.Error(), - }) - } - - return c.Status(fiber.StatusOK).JSON(domain.Response{ - Message: "Lesson updated successfully", - Data: updatedLesson, - }) -} - -// 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}) -} - -// CreateSubModuleCapstone godoc -// @Summary Create capstone under sub-module -// @Description Creates a capstone assessment with a new CAPSTONE question set, metadata, and ordered questions -// @Tags course-management -// @Accept json -// @Produce json -// @Param body body createSubModuleCapstoneReq true "Create capstone payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-module-capstones [post] -func (h *Handler) CreateSubModuleCapstone(c *fiber.Ctx) error { - var req createSubModuleCapstoneReq - 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) == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id and title are required"}) - } - if len(req.Questions) == 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "At least one question is required"}) - } - seenQ := make(map[int64]struct{}, len(req.Questions)) - for _, q := range req.Questions { - if q.QuestionID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Each question_id must be a positive integer"}) - } - if _, dup := seenQ[q.QuestionID]; dup { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Duplicate question_id values are not allowed"}) - } - seenQ[q.QuestionID] = struct{}{} - if _, err := h.questionsSvc.GetQuestionByID(c.Context(), q.QuestionID); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid question_id", Error: err.Error()}) - } - } - - ownerType := "SUB_MODULE" - shuffle := false - if req.ShuffleQuestions != nil { - shuffle = *req.ShuffleQuestions - } - status := "DRAFT" - if req.Status != nil && strings.TrimSpace(*req.Status) != "" { - status = strings.TrimSpace(*req.Status) - } - - title := strings.TrimSpace(req.Title) - createdSet, err := h.questionsSvc.CreateQuestionSet(c.Context(), domain.CreateQuestionSetInput{ - Title: title, - Description: req.Description, - SetType: string(domain.QuestionSetTypeCapstone), - OwnerType: &ownerType, - OwnerID: &req.SubModuleID, - BannerImage: req.Thumbnail, - TimeLimitMinutes: req.TimeLimitMinutes, - PassingScore: req.PassingScore, - ShuffleQuestions: &shuffle, - Status: &status, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create capstone question set", Error: err.Error()}) - } - - capRow, err := h.analyticsDB.CreateSubModuleCapstone(c.Context(), dbgen.CreateSubModuleCapstoneParams{ - SubModuleID: req.SubModuleID, - Title: title, - Description: toText(req.Description), - Tips: toText(req.Tips), - Thumbnail: toText(req.Thumbnail), - QuestionSetID: createdSet.ID, - Column7: intOrNil(req.DisplayOrder), - Column8: boolOrNil(req.IsActive), - }) - if err != nil { - _ = h.questionsSvc.DeleteQuestionSet(c.Context(), createdSet.ID) - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create capstone", Error: err.Error()}) - } - - for idx, cq := range req.Questions { - order := cq.DisplayOrder - if order == nil { - o := int32(idx) - order = &o - } - if _, err := h.questionsSvc.AddQuestionToSet(c.Context(), createdSet.ID, cq.QuestionID, order); err != nil { - _ = h.questionsSvc.DeleteQuestionSet(c.Context(), createdSet.ID) - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to attach capstone questions", Error: err.Error()}) - } - } - - detail, err := h.analyticsDB.GetSubModuleCapstoneByID(c.Context(), capRow.ID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Capstone created but failed to load detail", Error: err.Error()}) - } - items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), detail.QuestionSetID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Capstone created but failed to load questions", Error: err.Error()}) - } - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Capstone created", - Data: map[string]interface{}{ - "capstone": detail, - "questions": items, - }, - }) -} - -// GetSubModulePractices godoc -// @Summary Get practices under sub-module -// @Description Returns all active practices attached to a sub-module -// @Tags course-management -// @Accept json -// @Produce json -// @Param subModuleId path int true "Sub-module ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-modules/{subModuleId}/practices [get] -func (h *Handler) GetSubModulePractices(c *fiber.Ctx) error { - subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64) - if err != nil || subModuleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-module ID", - Error: "subModuleId must be a positive integer", - }) - } - - practices, err := h.analyticsDB.GetSubModulePractices(c.Context(), subModuleID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to load sub-module practices", - Error: err.Error(), - }) - } - if practices == nil { - practices = []dbgen.GetSubModulePracticesRow{} - } - - return c.JSON(domain.Response{ - Message: "Sub-module practices retrieved successfully", - Success: true, - StatusCode: fiber.StatusOK, - Data: map[string]interface{}{ - "practices": practices, - "total_count": len(practices), - }, - }) -} - -func practiceRowFromSubModuleQuestionSet(qs domain.QuestionSet) dbgen.GetSubModulePracticeByIDRow { - row := dbgen.GetSubModulePracticeByIDRow{ - ID: 0, - Title: qs.Title, - QuestionSetID: qs.ID, - DisplayOrder: 0, - Status: qs.Status, - SetType: qs.SetType, - IsActive: !strings.EqualFold(qs.Status, "ARCHIVED"), - } - if qs.OwnerID != nil { - row.SubModuleID = *qs.OwnerID - } - if qs.Description != nil { - row.Description = pgtype.Text{String: *qs.Description, Valid: true} - } - if qs.IntroVideoURL != nil { - row.IntroVideoUrl = pgtype.Text{String: *qs.IntroVideoURL, Valid: true} - } - return row -} - -// resolveCourseManagementPractice loads an active sub_module_practices row, or a SUB_MODULE-owned PRACTICE -// question_set with no bridge row (id is always question_sets.id in that case). -func (h *Handler) resolveCourseManagementPractice(ctx context.Context, id int64) (dbgen.GetSubModulePracticeByIDRow, error) { - row, err := h.analyticsDB.GetSubModulePracticeByID(ctx, id) - if err == nil { - return row, nil - } - if !errors.Is(err, pgx.ErrNoRows) { - return dbgen.GetSubModulePracticeByIDRow{}, err - } - qs, err := h.questionsSvc.GetQuestionSetByID(ctx, id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return dbgen.GetSubModulePracticeByIDRow{}, pgx.ErrNoRows - } - return dbgen.GetSubModulePracticeByIDRow{}, err - } - if !strings.EqualFold(qs.SetType, string(domain.QuestionSetTypePractice)) { - return dbgen.GetSubModulePracticeByIDRow{}, pgx.ErrNoRows - } - if qs.OwnerType == nil || !strings.EqualFold(*qs.OwnerType, "SUB_MODULE") || qs.OwnerID == nil { - return dbgen.GetSubModulePracticeByIDRow{}, pgx.ErrNoRows - } - out := practiceRowFromSubModuleQuestionSet(qs) - _, total, err := h.questionsSvc.GetQuestionSetItemsPaginated(ctx, qs.ID, nil, 1, 0) - if err == nil { - out.QuestionCount = total - } - return out, nil -} - -// GetSubModulePracticeByID godoc -// @Summary Get practice detail -// @Description Returns one practice. practiceId may be sub_module_practices.id, question_sets.id, or (if no bridge row) a SUB_MODULE PRACTICE question set id. -// @Tags course-management -// @Accept json -// @Produce json -// @Param practiceId path int true "Practice row id or question set 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/practices/{practiceId} [get] -func (h *Handler) GetSubModulePracticeByID(c *fiber.Ctx) error { - practiceID, err := strconv.ParseInt(c.Params("practiceId"), 10, 64) - if err != nil || practiceID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid practice ID", - Error: "practiceId must be a positive integer", - }) - } - - practice, err := h.resolveCourseManagementPractice(c.Context(), practiceID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Practice not found", - Error: err.Error(), - }) - } - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to load practice", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Practice retrieved successfully", - Success: true, - StatusCode: fiber.StatusOK, - Data: practice, - }) -} - -// GetSubModulePracticeDetail godoc -// @Summary Get practice with full question list -// @Description Returns practice metadata and ordered questions. practiceId may be sub_module_practices.id, linked question_sets.id, or a SUB_MODULE PRACTICE set id when no sub_module_practices row exists. -// @Tags course-management -// @Produce json -// @Param practiceId path int true "Practice row id or question set 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/practices/{practiceId}/detail [get] -func (h *Handler) GetSubModulePracticeDetail(c *fiber.Ctx) error { - practiceID, err := strconv.ParseInt(c.Params("practiceId"), 10, 64) - if err != nil || practiceID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid practice ID", - Error: "practiceId must be a positive integer", - }) - } - practice, err := h.resolveCourseManagementPractice(c.Context(), practiceID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Practice not found", - Error: err.Error(), - }) - } - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to load practice", - Error: err.Error(), - }) - } - const pageSize int32 = 500 - var allItems []domain.QuestionSetItemWithQuestion - var offset int32 - var totalCount int64 - for { - batch, total, err := h.questionsSvc.GetQuestionSetItemsPaginated(c.Context(), practice.QuestionSetID, nil, pageSize, offset) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to load practice questions", - Error: err.Error(), - }) - } - totalCount = total - allItems = append(allItems, batch...) - if int64(len(allItems)) >= total || len(batch) == 0 { - break - } - offset += pageSize - } - practice.QuestionCount = totalCount - return c.JSON(domain.Response{ - Message: "Practice retrieved successfully", - Success: true, - StatusCode: fiber.StatusOK, - Data: map[string]interface{}{ - "practice": practice, - "questions": questionSetItemsToRes(allItems), - }, - }) -} - -// GetSubModuleCapstones godoc -// @Summary List capstones under sub-module -// @Description Returns active capstones for a sub-module with question-set settings and question counts -// @Tags course-management -// @Produce json -// @Param subModuleId path int true "Sub-module ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/sub-modules/{subModuleId}/capstones [get] -func (h *Handler) GetSubModuleCapstones(c *fiber.Ctx) error { - subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64) - if err != nil || subModuleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-module ID", - Error: "subModuleId must be a valid positive integer", - }) - } - rows, err := h.analyticsDB.GetSubModuleCapstones(c.Context(), subModuleID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to load sub-module capstones", - Error: err.Error(), - }) - } - return c.JSON(domain.Response{ - Message: "Sub-module capstones retrieved successfully", - Data: map[string]interface{}{ - "capstones": rows, - "total_count": len(rows), - }, - }) -} - -// GetSubModuleCapstoneByID godoc -// @Summary Get capstone detail -// @Description Returns one capstone with question-set fields and the ordered question list -// @Tags course-management -// @Produce json -// @Param capstoneId path int true "Capstone 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/capstones/{capstoneId} [get] -func (h *Handler) GetSubModuleCapstoneByID(c *fiber.Ctx) error { - capstoneID, err := strconv.ParseInt(c.Params("capstoneId"), 10, 64) - if err != nil || capstoneID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid capstone ID", - Error: "capstoneId must be a valid positive integer", - }) - } - detail, err := h.analyticsDB.GetSubModuleCapstoneByID(c.Context(), capstoneID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Capstone not found", - Error: err.Error(), - }) - } - items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), detail.QuestionSetID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to load capstone questions", - Error: err.Error(), - }) - } - return c.JSON(domain.Response{ - Message: "Capstone retrieved successfully", - Data: map[string]interface{}{ - "capstone": detail, - "questions": items, - }, - }) -} - -func (h *Handler) GetSubModuleVideos(c *fiber.Ctx) error { - subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64) - if err != nil || subModuleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid sub-module ID", Error: "subModuleId must be a positive integer"}) - } - - videos, err := h.analyticsDB.GetSubModuleVideos(c.Context(), subModuleID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load sub-module videos", Error: err.Error()}) - } - - return c.JSON(domain.Response{ - Message: "Sub-module videos retrieved successfully", - Data: map[string]interface{}{ - "videos": videos, - "total_count": len(videos), - }, - }) -} - -func (h *Handler) UpdateSubModule(c *fiber.Ctx) error { - subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64) - if err != nil || subModuleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid sub-module ID", Error: "subModuleId must be a positive integer"}) - } - - current, err := h.analyticsDB.GetSubModuleByID(c.Context(), subModuleID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Sub-module not found", Error: err.Error()}) - } - - var req updateSubModuleReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) - } - - targetTitle := current.Title - if req.Title != nil { - t := strings.TrimSpace(*req.Title) - if t == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"}) - } - targetTitle = t - } - targetDesc := mergeTextField(current.Description, req.Description) - targetThumb := mergeTextField(current.Thumbnail, req.Thumbnail) - targetTips := mergeTextField(current.Tips, req.Tips) - targetOrder := current.DisplayOrder - if req.DisplayOrder != nil { - targetOrder = *req.DisplayOrder - } - targetActive := current.IsActive - if req.IsActive != nil { - targetActive = *req.IsActive - } - - updated, err := h.analyticsDB.UpdateSubModule(c.Context(), dbgen.UpdateSubModuleParams{ - Title: targetTitle, - Description: targetDesc, - Thumbnail: targetThumb, - Tips: targetTips, - DisplayOrder: targetOrder, - IsActive: targetActive, - ID: subModuleID, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update sub-module", Error: err.Error()}) - } - return c.JSON(domain.Response{Message: "Sub-module updated", Data: updated}) -} - -func (h *Handler) DeleteSubModule(c *fiber.Ctx) error { - subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64) - if err != nil || subModuleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid sub-module ID", Error: "subModuleId must be a positive integer"}) - } - - if err := h.analyticsDB.DeleteSubModuleCompat(c.Context(), subModuleID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete sub-module", Error: err.Error()}) - } - return c.JSON(domain.Response{Message: "Sub-module deleted"}) -} - -func (h *Handler) DeleteModule(c *fiber.Ctx) error { - moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64) - if err != nil || moduleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid module ID", Error: "moduleId must be a positive integer"}) - } - - if err := h.analyticsDB.DeleteModuleCompat(c.Context(), moduleID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete module", Error: err.Error()}) - } - return c.JSON(domain.Response{Message: "Module deleted"}) -} - -func (h *Handler) UpdateSubModuleVideo(c *fiber.Ctx) error { - videoID, err := strconv.ParseInt(c.Params("videoId"), 10, 64) - if err != nil || videoID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid video ID", Error: "videoId must be a positive integer"}) - } - - var req updateSubModuleVideoReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) - } - if req.Title == nil || strings.TrimSpace(*req.Title) == "" || req.VideoURL == nil || strings.TrimSpace(*req.VideoURL) == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title and video_url are required"}) - } - - description := "" - if req.Description != nil { - description = *req.Description - } - - if err := h.analyticsDB.UpdateSubModuleVideoCompat(c.Context(), videoID, *req.Title, description, *req.VideoURL); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update sub-module video", Error: err.Error()}) - } - return c.JSON(domain.Response{Message: "Sub-module video updated"}) -} - -func (h *Handler) DeleteSubModuleVideo(c *fiber.Ctx) error { - videoID, err := strconv.ParseInt(c.Params("videoId"), 10, 64) - if err != nil || videoID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid video ID", Error: "videoId must be a positive integer"}) - } - - if err := h.analyticsDB.DeleteSubModuleVideoCompat(c.Context(), videoID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete sub-module video", Error: err.Error()}) - } - return c.JSON(domain.Response{Message: "Sub-module video deleted"}) -} - -func (h *Handler) UpdatePractice(c *fiber.Ctx) error { - practiceID, err := strconv.ParseInt(c.Params("practiceId"), 10, 64) - if err != nil || practiceID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid practice ID", Error: "practiceId must be a positive integer"}) - } - - var req updatePracticeReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) - } - - if req.IsActive != nil { - if err := h.analyticsDB.UpdatePracticeStatusCompat(c.Context(), practiceID, *req.IsActive); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update practice status", Error: err.Error()}) - } - return c.JSON(domain.Response{Message: "Practice status updated"}) - } - - title := "" - if req.Title != nil { - title = *req.Title - } - description := "" - if req.Description != nil { - description = *req.Description - } - persona := "" - if req.Persona != nil { - persona = *req.Persona - } - if strings.TrimSpace(title) == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title is required"}) - } - - if err := h.analyticsDB.UpdatePracticeCompat(c.Context(), practiceID, title, description, persona); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update practice", Error: err.Error()}) - } - return c.JSON(domain.Response{Message: "Practice updated"}) -} - -func (h *Handler) DeletePractice(c *fiber.Ctx) error { - practiceID, err := strconv.ParseInt(c.Params("practiceId"), 10, 64) - if err != nil || practiceID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid practice ID", Error: "practiceId must be a positive integer"}) - } - - if err := h.analyticsDB.DeletePracticeCompat(c.Context(), practiceID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete practice", Error: err.Error()}) - } - return c.JSON(domain.Response{Message: "Practice deleted"}) -} - -// UpdateSubModuleCapstone godoc -// @Summary Update capstone -// @Description Updates capstone content, question-set assessment settings, and optionally replaces the question list -// @Tags course-management -// @Accept json -// @Produce json -// @Param capstoneId path int true "Capstone ID" -// @Param body body updateSubModuleCapstoneReq true "Update capstone payload" -// @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/capstones/{capstoneId} [put] -func (h *Handler) UpdateSubModuleCapstone(c *fiber.Ctx) error { - capstoneID, err := strconv.ParseInt(c.Params("capstoneId"), 10, 64) - if err != nil || capstoneID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid capstone ID", Error: "capstoneId must be a positive integer"}) - } - var req updateSubModuleCapstoneReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) - } - - cur, err := h.analyticsDB.GetSubModuleCapstoneByID(c.Context(), capstoneID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Capstone not found", Error: err.Error()}) - } - - targetTitle := cur.Title - if req.Title != nil { - t := strings.TrimSpace(*req.Title) - if t == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"}) - } - targetTitle = t - } - targetDesc := mergeTextField(cur.Description, req.Description) - targetTips := mergeTextField(cur.Tips, req.Tips) - targetThumb := mergeTextField(cur.Thumbnail, req.Thumbnail) - targetOrder := cur.DisplayOrder - if req.DisplayOrder != nil { - targetOrder = *req.DisplayOrder - } - targetActive := cur.IsActive - if req.IsActive != nil { - targetActive = *req.IsActive - } - - if _, err := h.analyticsDB.UpdateSubModuleCapstone(c.Context(), dbgen.UpdateSubModuleCapstoneParams{ - Title: targetTitle, - Description: targetDesc, - Tips: targetTips, - Thumbnail: targetThumb, - DisplayOrder: targetOrder, - IsActive: targetActive, - ID: capstoneID, - }); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update capstone", Error: err.Error()}) - } - - qs, err := h.questionsSvc.GetQuestionSetByID(c.Context(), cur.QuestionSetID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load capstone question set", Error: err.Error()}) - } - - tlm := qs.TimeLimitMinutes - if req.TimeLimitMinutes != nil { - tlm = req.TimeLimitMinutes - } - ps := qs.PassingScore - if req.PassingScore != nil { - ps = req.PassingScore - } - sh := qs.ShuffleQuestions - if req.ShuffleQuestions != nil { - sh = *req.ShuffleQuestions - } - st := qs.Status - if req.Status != nil && strings.TrimSpace(*req.Status) != "" { - st = strings.TrimSpace(*req.Status) - } - - if err := h.questionsSvc.UpdateQuestionSet(c.Context(), cur.QuestionSetID, domain.CreateQuestionSetInput{ - Title: targetTitle, - Description: stringPtrFromPgText(targetDesc), - BannerImage: stringPtrFromPgText(targetThumb), - Persona: qs.Persona, - TimeLimitMinutes: tlm, - PassingScore: ps, - ShuffleQuestions: &sh, - Status: &st, - SubCourseVideoID: qs.SubCourseVideoID, - IntroVideoURL: qs.IntroVideoURL, - }); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update capstone question set", Error: err.Error()}) - } - - if req.Questions != nil { - seen := make(map[int64]struct{}, len(req.Questions)) - for idx, q := range req.Questions { - if q.QuestionID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Each question_id must be a positive integer"}) - } - if _, exists := seen[q.QuestionID]; exists { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Duplicate question_id values are not allowed"}) - } - seen[q.QuestionID] = struct{}{} - if _, err := h.questionsSvc.GetQuestionByID(c.Context(), q.QuestionID); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid question_id in questions payload", Error: err.Error()}) - } - order := q.DisplayOrder - if order == nil { - o := int32(idx) - order = &o - } - if _, err := h.questionsSvc.AddQuestionToSet(c.Context(), cur.QuestionSetID, q.QuestionID, order); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to upsert capstone question", Error: err.Error()}) - } - } - existingItems, err := h.questionsSvc.GetQuestionSetItems(c.Context(), cur.QuestionSetID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load existing capstone questions", Error: err.Error()}) - } - for _, item := range existingItems { - if _, keep := seen[item.QuestionID]; keep { - continue - } - if err := h.questionsSvc.RemoveQuestionFromSet(c.Context(), cur.QuestionSetID, item.QuestionID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to remove capstone question", Error: err.Error()}) - } - } - } - - detail, err := h.analyticsDB.GetSubModuleCapstoneByID(c.Context(), capstoneID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Capstone updated but failed to load detail", Error: err.Error()}) - } - items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), detail.QuestionSetID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Capstone updated but failed to load questions", Error: err.Error()}) - } - return c.JSON(domain.Response{ - Message: "Capstone updated successfully", - Data: map[string]interface{}{ - "capstone": detail, - "questions": items, - }, - }) -} - -// DeleteCapstone godoc -// @Summary Delete capstone -// @Description Deletes the capstone and its backing question set (and question items) -// @Tags course-management -// @Produce json -// @Param capstoneId path int true "Capstone ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/capstones/{capstoneId} [delete] -func (h *Handler) DeleteCapstone(c *fiber.Ctx) error { - capstoneID, err := strconv.ParseInt(c.Params("capstoneId"), 10, 64) - if err != nil || capstoneID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid capstone ID", Error: "capstoneId must be a positive integer"}) - } - if err := h.analyticsDB.DeleteCapstoneCompat(c.Context(), capstoneID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete capstone", Error: err.Error()}) - } - return c.JSON(domain.Response{Message: "Capstone deleted"}) -} - -// CreateModuleCapstone godoc -// @Summary Create module capstone -// @Description Creates a module-level capstone with a new CAPSTONE question set and ordered questions -// @Tags course-management -// @Accept json -// @Produce json -// @Param body body createModuleCapstoneReq true "Create module capstone payload" -// @Success 201 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/module-capstones [post] -func (h *Handler) CreateModuleCapstone(c *fiber.Ctx) error { - var req createModuleCapstoneReq - 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"}) - } - if len(req.Questions) == 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "At least one question is required"}) - } - seenQ := make(map[int64]struct{}, len(req.Questions)) - for _, q := range req.Questions { - if q.QuestionID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Each question_id must be a positive integer"}) - } - if _, dup := seenQ[q.QuestionID]; dup { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Duplicate question_id values are not allowed"}) - } - seenQ[q.QuestionID] = struct{}{} - if _, err := h.questionsSvc.GetQuestionByID(c.Context(), q.QuestionID); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid question_id", Error: err.Error()}) - } - } - - ownerType := "MODULE" - shuffle := false - if req.ShuffleQuestions != nil { - shuffle = *req.ShuffleQuestions - } - status := "DRAFT" - if req.Status != nil && strings.TrimSpace(*req.Status) != "" { - status = strings.TrimSpace(*req.Status) - } - - title := strings.TrimSpace(req.Title) - createdSet, err := h.questionsSvc.CreateQuestionSet(c.Context(), domain.CreateQuestionSetInput{ - Title: title, - Description: req.Description, - SetType: string(domain.QuestionSetTypeCapstone), - OwnerType: &ownerType, - OwnerID: &req.ModuleID, - BannerImage: req.Thumbnail, - TimeLimitMinutes: req.TimeLimitMinutes, - PassingScore: req.PassingScore, - ShuffleQuestions: &shuffle, - Status: &status, - }) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create module capstone question set", Error: err.Error()}) - } - - capRow, err := h.analyticsDB.CreateModuleCapstone(c.Context(), dbgen.CreateModuleCapstoneParams{ - ModuleID: req.ModuleID, - Title: title, - Description: toText(req.Description), - Tips: toText(req.Tips), - Thumbnail: toText(req.Thumbnail), - QuestionSetID: createdSet.ID, - Column7: intOrNil(req.DisplayOrder), - Column8: boolOrNil(req.IsActive), - }) - if err != nil { - _ = h.questionsSvc.DeleteQuestionSet(c.Context(), createdSet.ID) - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create module capstone", Error: err.Error()}) - } - - for idx, cq := range req.Questions { - order := cq.DisplayOrder - if order == nil { - o := int32(idx) - order = &o - } - if _, err := h.questionsSvc.AddQuestionToSet(c.Context(), createdSet.ID, cq.QuestionID, order); err != nil { - _ = h.questionsSvc.DeleteQuestionSet(c.Context(), createdSet.ID) - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to attach module capstone questions", Error: err.Error()}) - } - } - - detail, err := h.analyticsDB.GetModuleCapstoneByID(c.Context(), capRow.ID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Module capstone created but failed to load detail", Error: err.Error()}) - } - items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), detail.QuestionSetID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Module capstone created but failed to load questions", Error: err.Error()}) - } - - return c.Status(fiber.StatusCreated).JSON(domain.Response{ - Message: "Module capstone created", - Data: map[string]interface{}{ - "capstone": detail, - "questions": items, - }, - }) -} - -// GetModuleCapstones godoc -// @Summary List capstones under module -// @Description Returns active module capstones with question-set settings and question counts -// @Tags course-management -// @Produce json -// @Param moduleId path int true "Module ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/modules/{moduleId}/capstones [get] -func (h *Handler) GetModuleCapstones(c *fiber.Ctx) error { - moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64) - if err != nil || moduleID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid module ID", - Error: "moduleId must be a valid positive integer", - }) - } - rows, err := h.analyticsDB.GetModuleCapstones(c.Context(), moduleID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to load module capstones", - Error: err.Error(), - }) - } - return c.JSON(domain.Response{ - Message: "Module capstones retrieved successfully", - Data: map[string]interface{}{ - "capstones": rows, - "total_count": len(rows), - }, - }) -} - -// GetModuleCapstoneByID godoc -// @Summary Get module capstone detail -// @Description Returns one module capstone with question-set fields and the ordered question list -// @Tags course-management -// @Produce json -// @Param moduleCapstoneId path int true "Module capstone 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/module-capstones/{moduleCapstoneId} [get] -func (h *Handler) GetModuleCapstoneByID(c *fiber.Ctx) error { - capstoneID, err := strconv.ParseInt(c.Params("moduleCapstoneId"), 10, 64) - if err != nil || capstoneID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid module capstone ID", - Error: "moduleCapstoneId must be a valid positive integer", - }) - } - detail, err := h.analyticsDB.GetModuleCapstoneByID(c.Context(), capstoneID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Module capstone not found", - Error: err.Error(), - }) - } - items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), detail.QuestionSetID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to load module capstone questions", - Error: err.Error(), - }) - } - return c.JSON(domain.Response{ - Message: "Module capstone retrieved successfully", - Data: map[string]interface{}{ - "capstone": detail, - "questions": items, - }, - }) -} - -// UpdateModuleCapstone godoc -// @Summary Update module capstone -// @Description Updates module capstone content, question-set assessment settings, and optionally replaces the question list -// @Tags course-management -// @Accept json -// @Produce json -// @Param moduleCapstoneId path int true "Module capstone ID" -// @Param body body updateModuleCapstoneReq true "Update module capstone payload" -// @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/module-capstones/{moduleCapstoneId} [put] -func (h *Handler) UpdateModuleCapstone(c *fiber.Ctx) error { - capstoneID, err := strconv.ParseInt(c.Params("moduleCapstoneId"), 10, 64) - if err != nil || capstoneID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid module capstone ID", Error: "moduleCapstoneId must be a positive integer"}) - } - var req updateModuleCapstoneReq - if err := c.BodyParser(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()}) - } - - cur, err := h.analyticsDB.GetModuleCapstoneByID(c.Context(), capstoneID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Module capstone not found", Error: err.Error()}) - } - - targetTitle := cur.Title - if req.Title != nil { - t := strings.TrimSpace(*req.Title) - if t == "" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"}) - } - targetTitle = t - } - targetDesc := mergeTextField(cur.Description, req.Description) - targetTips := mergeTextField(cur.Tips, req.Tips) - targetThumb := mergeTextField(cur.Thumbnail, req.Thumbnail) - targetOrder := cur.DisplayOrder - if req.DisplayOrder != nil { - targetOrder = *req.DisplayOrder - } - targetActive := cur.IsActive - if req.IsActive != nil { - targetActive = *req.IsActive - } - - if _, err := h.analyticsDB.UpdateModuleCapstone(c.Context(), dbgen.UpdateModuleCapstoneParams{ - Title: targetTitle, - Description: targetDesc, - Tips: targetTips, - Thumbnail: targetThumb, - DisplayOrder: targetOrder, - IsActive: targetActive, - ID: capstoneID, - }); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update module capstone", Error: err.Error()}) - } - - qs, err := h.questionsSvc.GetQuestionSetByID(c.Context(), cur.QuestionSetID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load module capstone question set", Error: err.Error()}) - } - - tlm := qs.TimeLimitMinutes - if req.TimeLimitMinutes != nil { - tlm = req.TimeLimitMinutes - } - ps := qs.PassingScore - if req.PassingScore != nil { - ps = req.PassingScore - } - sh := qs.ShuffleQuestions - if req.ShuffleQuestions != nil { - sh = *req.ShuffleQuestions - } - st := qs.Status - if req.Status != nil && strings.TrimSpace(*req.Status) != "" { - st = strings.TrimSpace(*req.Status) - } - - if err := h.questionsSvc.UpdateQuestionSet(c.Context(), cur.QuestionSetID, domain.CreateQuestionSetInput{ - Title: targetTitle, - Description: stringPtrFromPgText(targetDesc), - BannerImage: stringPtrFromPgText(targetThumb), - Persona: qs.Persona, - TimeLimitMinutes: tlm, - PassingScore: ps, - ShuffleQuestions: &sh, - Status: &st, - SubCourseVideoID: qs.SubCourseVideoID, - IntroVideoURL: qs.IntroVideoURL, - }); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update module capstone question set", Error: err.Error()}) - } - - if req.Questions != nil { - seen := make(map[int64]struct{}, len(req.Questions)) - for idx, q := range req.Questions { - if q.QuestionID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Each question_id must be a positive integer"}) - } - if _, exists := seen[q.QuestionID]; exists { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Duplicate question_id values are not allowed"}) - } - seen[q.QuestionID] = struct{}{} - if _, err := h.questionsSvc.GetQuestionByID(c.Context(), q.QuestionID); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid question_id in questions payload", Error: err.Error()}) - } - order := q.DisplayOrder - if order == nil { - o := int32(idx) - order = &o - } - if _, err := h.questionsSvc.AddQuestionToSet(c.Context(), cur.QuestionSetID, q.QuestionID, order); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to upsert module capstone question", Error: err.Error()}) - } - } - existingItems, err := h.questionsSvc.GetQuestionSetItems(c.Context(), cur.QuestionSetID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load existing module capstone questions", Error: err.Error()}) - } - for _, item := range existingItems { - if _, keep := seen[item.QuestionID]; keep { - continue - } - if err := h.questionsSvc.RemoveQuestionFromSet(c.Context(), cur.QuestionSetID, item.QuestionID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to remove module capstone question", Error: err.Error()}) - } - } - } - - detail, err := h.analyticsDB.GetModuleCapstoneByID(c.Context(), capstoneID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Module capstone updated but failed to load detail", Error: err.Error()}) - } - items, err := h.questionsSvc.GetQuestionSetItems(c.Context(), detail.QuestionSetID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Module capstone updated but failed to load questions", Error: err.Error()}) - } - return c.JSON(domain.Response{ - Message: "Module capstone updated successfully", - Data: map[string]interface{}{ - "capstone": detail, - "questions": items, - }, - }) -} - -// DeleteModuleCapstone godoc -// @Summary Delete module capstone -// @Description Deletes the module capstone and its backing question set -// @Tags course-management -// @Produce json -// @Param moduleCapstoneId path int true "Module capstone ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/course-management/module-capstones/{moduleCapstoneId} [delete] -func (h *Handler) DeleteModuleCapstone(c *fiber.Ctx) error { - capstoneID, err := strconv.ParseInt(c.Params("moduleCapstoneId"), 10, 64) - if err != nil || capstoneID <= 0 { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid module capstone ID", Error: "moduleCapstoneId must be a positive integer"}) - } - if err := h.analyticsDB.DeleteModuleCapstoneCompat(c.Context(), capstoneID); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete module capstone", Error: err.Error()}) - } - return c.JSON(domain.Response{Message: "Module capstone deleted"}) -} diff --git a/internal/web_server/handlers/maintenance_handler.go b/internal/web_server/handlers/maintenance_handler.go deleted file mode 100644 index d9d87aa..0000000 --- a/internal/web_server/handlers/maintenance_handler.go +++ /dev/null @@ -1,277 +0,0 @@ -package handlers - -import ( - "Yimaru-Backend/internal/domain" - "crypto/subtle" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - - "github.com/gofiber/fiber/v2" -) - -type resetAndReseedReq struct { - Confirm string `json:"confirm"` -} - -type clearCourseManagementReq struct { - Confirm string `json:"confirm"` -} - -func extractInsertStatement(sqlContent string, tableName string) (string, bool) { - pattern := fmt.Sprintf(`(?is)INSERT\s+INTO\s+%s\b.*?;`, regexp.QuoteMeta(tableName)) - re := regexp.MustCompile(pattern) - statement := strings.TrimSpace(re.FindString(sqlContent)) - if statement == "" { - return "", false - } - return statement, true -} - -func resolveSeedDir(seedDir string) (string, error) { - cleanSeedDir := strings.TrimSpace(seedDir) - if cleanSeedDir == "" { - cleanSeedDir = "db/data" - } - - // If absolute, use directly. - if filepath.IsAbs(cleanSeedDir) { - info, err := os.Stat(cleanSeedDir) - if err != nil { - return "", err - } - if !info.IsDir() { - return "", fmt.Errorf("seed dir is not a directory: %s", cleanSeedDir) - } - return cleanSeedDir, nil - } - - candidates := make([]string, 0, 5) - - // 1) Relative to current working directory. - candidates = append(candidates, filepath.Clean(cleanSeedDir)) - - // 2) Relative to executable directory (and parents). - if exePath, err := os.Executable(); err == nil { - exeDir := filepath.Dir(exePath) - candidates = append(candidates, - filepath.Join(exeDir, cleanSeedDir), - filepath.Join(exeDir, "..", cleanSeedDir), - filepath.Join(exeDir, "..", "..", cleanSeedDir), - ) - } - - for _, candidate := range candidates { - info, err := os.Stat(candidate) - if err != nil { - continue - } - if info.IsDir() { - return candidate, nil - } - } - - return "", fmt.Errorf("seed directory not found (tried: %s)", strings.Join(candidates, ", ")) -} - -// ResetAndReseedDatabase godoc -// @Summary Reset and reseed database -// @Description Truncates course_categories, courses, and sub_courses. If seed SQL contains INSERTs for those tables (e.g. 007_course_management_seed.sql), they are replayed; otherwise tables are left empty after truncate. -// @Tags internal -// @Accept json -// @Produce json -// @Param X-Seed-Reset-Token header string true "Reset token configured in DB_RESET_RESEED_TOKEN" -// @Param body body resetAndReseedReq true "Confirmation payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 403 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/internal/db/reset-reseed [post] -func (h *Handler) ResetAndReseedDatabase(c *fiber.Ctx) error { - if h.Cfg == nil || !h.Cfg.DBResetReseedEnabled { - return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ - Message: "Operation is disabled", - Error: "DB_RESET_RESEED_ENABLED must be set to true", - }) - } - - var req resetAndReseedReq - 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.Confirm) != "RESET_AND_RESEED" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Confirmation required", - Error: `set confirm to "RESET_AND_RESEED"`, - }) - } - - expectedToken := strings.TrimSpace(h.Cfg.DBResetReseedToken) - if expectedToken != "" { - providedToken := strings.TrimSpace(c.Get("X-Seed-Reset-Token")) - if subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 { - return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ - Message: "Invalid reset token", - Error: "missing or invalid X-Seed-Reset-Token", - }) - } - } - - seedDir, err := resolveSeedDir(h.Cfg.DBSeedDir) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to resolve seed directory", - Error: err.Error(), - }) - } - seedCandidates := []string{ - filepath.Join(seedDir, "007_course_management_seed.sql"), - filepath.Join(seedDir, "001_initial_seed_data.sql"), - } - tableNames := []string{"course_categories", "courses", "sub_courses"} - statements := map[string]string{} - statementSource := map[string]string{} - - for _, file := range seedCandidates { - contentBytes, readErr := os.ReadFile(file) - if readErr != nil { - continue - } - content := string(contentBytes) - for _, tableName := range tableNames { - if _, exists := statements[tableName]; exists { - continue - } - if stmt, ok := extractInsertStatement(content, tableName); ok { - statements[tableName] = stmt - statementSource[tableName] = file - } - } - } - - missing := 0 - for _, tableName := range tableNames { - if _, ok := statements[tableName]; !ok { - missing++ - } - } - if missing != 0 && missing != len(tableNames) { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Incomplete course seed SQL", - Error: "seed files must define INSERT for all of course_categories, courses, and sub_courses, or none of them (truncate-only)", - }) - } - - var sqlBuilder strings.Builder - sqlBuilder.WriteString("BEGIN;\n") - sqlBuilder.WriteString("TRUNCATE TABLE sub_courses, courses, course_categories RESTART IDENTITY CASCADE;\n") - if missing == 0 { - for _, tableName := range tableNames { - sqlBuilder.WriteString("\n-- ") - sqlBuilder.WriteString(tableName) - sqlBuilder.WriteString(" from ") - sqlBuilder.WriteString(statementSource[tableName]) - sqlBuilder.WriteString("\n") - sqlBuilder.WriteString(statements[tableName]) - sqlBuilder.WriteString("\n") - } - } - sqlBuilder.WriteString("COMMIT;") - - if _, err := h.analyticsDB.ExecRaw(c.Context(), sqlBuilder.String()); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to reset and reseed database", - Error: err.Error(), - }) - } - - msg := "Course management hierarchy reset and reseed completed successfully" - if missing == len(tableNames) { - msg = "Course management hierarchy truncated successfully (no INSERT seed configured; tables empty)" - } - return c.JSON(domain.Response{ - Message: msg, - Data: map[string]interface{}{ - "seed_dir": seedDir, - "tables": tableNames, - "sources": statementSource, - "reseeded": missing == 0, - "truncate_only": missing == len(tableNames), - }, - Success: true, - StatusCode: fiber.StatusOK, - }) -} - -// ClearCourseManagementData godoc -// @Summary Clear course management hierarchy data only -// @Description Truncates course_categories, courses, and sub_courses (same scope as reset-reseed) without re-inserting seed SQL. -// @Tags internal -// @Accept json -// @Produce json -// @Param X-Seed-Reset-Token header string false "Optional token when DB_RESET_RESEED_TOKEN is set" -// @Param body body clearCourseManagementReq true "Confirmation payload" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 403 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/internal/db/clear-course-management [post] -func (h *Handler) ClearCourseManagementData(c *fiber.Ctx) error { - if h.Cfg == nil || !h.Cfg.DBResetReseedEnabled { - return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ - Message: "Operation is disabled", - Error: "internal course management maintenance is disabled", - }) - } - - var req clearCourseManagementReq - 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.Confirm) != "CLEAR_COURSE_MANAGEMENT" { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Confirmation required", - Error: `set confirm to "CLEAR_COURSE_MANAGEMENT"`, - }) - } - - expectedToken := strings.TrimSpace(h.Cfg.DBResetReseedToken) - if expectedToken != "" { - providedToken := strings.TrimSpace(c.Get("X-Seed-Reset-Token")) - if subtle.ConstantTimeCompare([]byte(providedToken), []byte(expectedToken)) != 1 { - return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ - Message: "Invalid reset token", - Error: "missing or invalid X-Seed-Reset-Token", - }) - } - } - - tableNames := []string{"course_categories", "courses", "sub_courses"} - sql := `BEGIN; -TRUNCATE TABLE sub_courses, courses, course_categories RESTART IDENTITY CASCADE; -COMMIT;` - - if _, err := h.analyticsDB.ExecRaw(c.Context(), sql); err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ - Message: "Failed to clear course management data", - Error: err.Error(), - }) - } - - return c.JSON(domain.Response{ - Message: "Course management hierarchy cleared successfully (no re-seed)", - Data: map[string]interface{}{ - "tables": tableNames, - }, - Success: true, - StatusCode: fiber.StatusOK, - }) -} diff --git a/internal/web_server/handlers/program_handler.go b/internal/web_server/handlers/program_handler.go new file mode 100644 index 0000000..801ded9 --- /dev/null +++ b/internal/web_server/handlers/program_handler.go @@ -0,0 +1,227 @@ +package handlers + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/services/programs" + "context" + "errors" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +// CreateProgram godoc +// @Summary Create program +// @Description Create a top-level LMS program +// @Tags programs +// @Accept json +// @Produce json +// @Param body body domain.CreateProgramInput true "Program" +// @Success 201 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/programs [post] +func (h *Handler) CreateProgram(c *fiber.Ctx) error { + var req domain.CreateProgramInput + 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: firstValidationError(valErrs), + }) + } + p, err := h.programSvc.Create(c.Context(), req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to create program", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionProgramCreated, domain.ResourceProgram, &p.ID, "Created program: "+p.Name, nil, &ip, &ua) + + return c.Status(fiber.StatusCreated).JSON(domain.Response{ + Message: "Program created successfully", + Data: p, + Success: true, + StatusCode: fiber.StatusCreated, + }) +} + +// ListPrograms godoc +// @Summary List programs +// @Description Paginated list of programs +// @Tags programs +// @Produce json +// @Param limit query int false "Page size" default(20) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} domain.Response +// @Failure 500 {object} domain.ErrorResponse +// @Router /api/v1/programs [get] +func (h *Handler) ListPrograms(c *fiber.Ctx) error { + limit, _ := strconv.Atoi(c.Query("limit", "20")) + offset, _ := strconv.Atoi(c.Query("offset", "0")) + items, total, err := h.programSvc.List(c.Context(), int32(limit), int32(offset)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to list programs", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Programs retrieved successfully", + Data: fiber.Map{ + "programs": items, + "total_count": total, + "limit": limit, + "offset": offset, + }, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// GetProgram godoc +// @Summary Get program by ID +// @Tags programs +// @Produce json +// @Param id path int true "Program ID" +// @Success 200 {object} domain.Response +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/programs/{id} [get] +func (h *Handler) GetProgram(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 program id", + Error: err.Error(), + }) + } + p, err := h.programSvc.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, programs.ErrProgramNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Program not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to load program", + Error: err.Error(), + }) + } + return c.JSON(domain.Response{ + Message: "Program retrieved successfully", + Data: p, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// UpdateProgram godoc +// @Summary Update program +// @Tags programs +// @Accept json +// @Produce json +// @Param id path int true "Program ID" +// @Param body body domain.UpdateProgramInput true "Fields to update" +// @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/programs/{id} [put] +func (h *Handler) UpdateProgram(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 program id", + Error: err.Error(), + }) + } + var req domain.UpdateProgramInput + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid request body", + Error: err.Error(), + }) + } + p, err := h.programSvc.Update(c.Context(), id, req) + if err != nil { + if errors.Is(err, programs.ErrProgramNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Program not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to update program", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionProgramUpdated, domain.ResourceProgram, &p.ID, "Updated program: "+p.Name, nil, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Program updated successfully", + Data: p, + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +// DeleteProgram godoc +// @Summary Delete program +// @Tags programs +// @Param id path int true "Program ID" +// @Success 200 {object} domain.Response +// @Failure 404 {object} domain.ErrorResponse +// @Router /api/v1/programs/{id} [delete] +func (h *Handler) DeleteProgram(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 program id", + Error: err.Error(), + }) + } + if err := h.programSvc.Delete(c.Context(), id); err != nil { + if errors.Is(err, programs.ErrProgramNotFound) { + return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ + Message: "Program not found", + Error: err.Error(), + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ + Message: "Failed to delete program", + Error: err.Error(), + }) + } + actorID := c.Locals("user_id").(int64) + actorRole := string(c.Locals("role").(domain.Role)) + ip := c.IP() + ua := c.Get("User-Agent") + go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionProgramDeleted, domain.ResourceProgram, &id, "Deleted program", nil, &ip, &ua) + + return c.JSON(domain.Response{ + Message: "Program deleted successfully", + Success: true, + StatusCode: fiber.StatusOK, + }) +} + +func firstValidationError(errs map[string]string) string { + for _, v := range errs { + return v + } + return "validation error" +} diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 6dc0b3a..f97f587 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -524,7 +524,6 @@ type createQuestionSetReq struct { PassingScore *int32 `json:"passing_score"` ShuffleQuestions *bool `json:"shuffle_questions"` Status *string `json:"status"` - SubCourseVideoID *int64 `json:"sub_course_video_id"` IntroVideoURL *string `json:"intro_video_url"` } @@ -540,9 +539,8 @@ type questionSetRes struct { TimeLimitMinutes *int32 `json:"time_limit_minutes,omitempty"` PassingScore *int32 `json:"passing_score,omitempty"` ShuffleQuestions bool `json:"shuffle_questions"` - Status string `json:"status"` - SubCourseVideoID *int64 `json:"sub_course_video_id,omitempty"` - IntroVideoURL *string `json:"intro_video_url,omitempty"` + Status string `json:"status"` + IntroVideoURL *string `json:"intro_video_url,omitempty"` CreatedAt string `json:"created_at"` QuestionCount *int64 `json:"question_count,omitempty"` } @@ -552,18 +550,20 @@ type listQuestionSetsRes struct { TotalCount int64 `json:"total_count"` } -func isSubCoursePractice(set domain.QuestionSet) bool { - return strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) && - set.OwnerType != nil && - strings.EqualFold(*set.OwnerType, "SUB_COURSE") +func isSequenceGatedPractice(set domain.QuestionSet) bool { + if !strings.EqualFold(set.SetType, string(domain.QuestionSetTypePractice)) || set.OwnerType == nil { + return false + } + ot := strings.ToUpper(strings.TrimSpace(*set.OwnerType)) + return ot == "SUB_COURSE" || ot == "SUB_MODULE" } func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error { role := c.Locals("role").(domain.Role) - if role != domain.RoleStudent || !isSubCoursePractice(set) { + if role != domain.RoleStudent || !isSequenceGatedPractice(set) { return nil } - if !strings.EqualFold(set.Status, string(domain.ContentStatusPublished)) { + if !strings.EqualFold(set.Status, "PUBLISHED") { return fiber.NewError(fiber.StatusNotFound, "Practice not found") } @@ -607,10 +607,10 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error { } if strings.EqualFold(req.SetType, string(domain.QuestionSetTypeInitialAssessment)) { - if req.OwnerType == nil || !strings.EqualFold(*req.OwnerType, "SUB_COURSE") || req.OwnerID == nil { + if req.OwnerType == nil || req.OwnerID == nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid initial assessment ownership", - Error: "INITIAL_ASSESSMENT question sets must include owner_type=SUB_COURSE and owner_id", + Error: "INITIAL_ASSESSMENT question sets must include owner_type and owner_id", }) } } @@ -627,7 +627,6 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error { PassingScore: req.PassingScore, ShuffleQuestions: req.ShuffleQuestions, Status: req.Status, - SubCourseVideoID: req.SubCourseVideoID, IntroVideoURL: req.IntroVideoURL, } @@ -661,67 +660,12 @@ func (h *Handler) CreateQuestionSet(c *fiber.Ctx) error { PassingScore: set.PassingScore, ShuffleQuestions: set.ShuffleQuestions, Status: set.Status, - SubCourseVideoID: set.SubCourseVideoID, IntroVideoURL: set.IntroVideoURL, CreatedAt: set.CreatedAt.String(), }, }) } -// GetSubCourseEntryAssessmentSet godoc -// @Summary Get entry assessment set for a sub-course -// @Description Returns the published INITIAL_ASSESSMENT question set for the given sub-course -// @Tags question-sets -// @Produce json -// @Param subCourseId path int true "Sub-course ID" -// @Success 200 {object} domain.Response -// @Failure 400 {object} domain.ErrorResponse -// @Failure 404 {object} domain.ErrorResponse -// @Failure 500 {object} domain.ErrorResponse -// @Router /api/v1/question-sets/sub-courses/{subCourseId}/entry-assessment [get] -func (h *Handler) GetSubCourseEntryAssessmentSet(c *fiber.Ctx) error { - subCourseIDStr := c.Params("subCourseId") - subCourseID, err := strconv.ParseInt(subCourseIDStr, 10, 64) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ - Message: "Invalid sub-course ID", - Error: err.Error(), - }) - } - - set, err := h.questionsSvc.GetSubCourseInitialAssessmentSet(c.Context(), subCourseID) - if err != nil { - return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ - Message: "Entry assessment set not found for sub-course", - Error: err.Error(), - }) - } - - count, _ := h.questionsSvc.CountQuestionsInSet(c.Context(), set.ID) - - return c.JSON(domain.Response{ - Message: "Entry assessment set retrieved successfully", - Data: questionSetRes{ - ID: set.ID, - Title: set.Title, - Description: set.Description, - SetType: set.SetType, - OwnerType: set.OwnerType, - OwnerID: set.OwnerID, - BannerImage: set.BannerImage, - Persona: set.Persona, - TimeLimitMinutes: set.TimeLimitMinutes, - PassingScore: set.PassingScore, - ShuffleQuestions: set.ShuffleQuestions, - Status: set.Status, - SubCourseVideoID: set.SubCourseVideoID, - IntroVideoURL: set.IntroVideoURL, - CreatedAt: set.CreatedAt.String(), - QuestionCount: &count, - }, - }) -} - // GetQuestionSetByID godoc // @Summary Get question set by ID // @Description Returns a question set with question count @@ -778,7 +722,6 @@ func (h *Handler) GetQuestionSetByID(c *fiber.Ctx) error { PassingScore: set.PassingScore, ShuffleQuestions: set.ShuffleQuestions, Status: set.Status, - SubCourseVideoID: set.SubCourseVideoID, IntroVideoURL: set.IntroVideoURL, CreatedAt: set.CreatedAt.String(), QuestionCount: &count, @@ -834,7 +777,6 @@ func (h *Handler) GetQuestionSetsByType(c *fiber.Ctx) error { PassingScore: s.PassingScore, ShuffleQuestions: s.ShuffleQuestions, Status: s.Status, - SubCourseVideoID: s.SubCourseVideoID, IntroVideoURL: s.IntroVideoURL, CreatedAt: s.CreatedAt.String(), }) @@ -901,7 +843,6 @@ func (h *Handler) GetQuestionSetsByOwner(c *fiber.Ctx) error { PassingScore: s.PassingScore, ShuffleQuestions: s.ShuffleQuestions, Status: s.Status, - SubCourseVideoID: s.SubCourseVideoID, IntroVideoURL: s.IntroVideoURL, CreatedAt: s.CreatedAt.String(), }) @@ -923,9 +864,8 @@ type updateQuestionSetReq struct { TimeLimitMinutes *int32 `json:"time_limit_minutes"` PassingScore *int32 `json:"passing_score"` ShuffleQuestions *bool `json:"shuffle_questions"` - Status *string `json:"status"` - SubCourseVideoID *int64 `json:"sub_course_video_id"` - IntroVideoURL *string `json:"intro_video_url"` + Status *string `json:"status"` + IntroVideoURL *string `json:"intro_video_url"` } // UpdateQuestionSet godoc @@ -972,7 +912,6 @@ func (h *Handler) UpdateQuestionSet(c *fiber.Ctx) error { PassingScore: req.PassingScore, ShuffleQuestions: req.ShuffleQuestions, Status: req.Status, - SubCourseVideoID: req.SubCourseVideoID, IntroVideoURL: req.IntroVideoURL, } @@ -1319,7 +1258,7 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error { Error: err.Error(), }) } - if !isSubCoursePractice(set) || !strings.EqualFold(set.Status, string(domain.ContentStatusPublished)) { + if !isSequenceGatedPractice(set) || !strings.EqualFold(set.Status, "PUBLISHED") { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Practice not found", }) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 348611e..2b56bb0 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -14,8 +14,8 @@ import ( func (a *App) initAppRoutes() { h := handlers.New( a.assessmentSvc, - a.courseSvc, a.questionsSvc, + a.programSvc, a.subscriptionsSvc, a.arifpaySvc, a.logger, @@ -67,6 +67,13 @@ func (a *App) initAppRoutes() { }) }) + // Programs (LMS top-level) + groupV1.Post("/programs", a.authMiddleware, a.RequirePermission("programs.create"), h.CreateProgram) + groupV1.Get("/programs", a.authMiddleware, a.RequirePermission("programs.list"), h.ListPrograms) + groupV1.Get("/programs/:id", a.authMiddleware, a.RequirePermission("programs.get"), h.GetProgram) + groupV1.Put("/programs/:id", a.authMiddleware, a.RequirePermission("programs.update"), h.UpdateProgram) + groupV1.Delete("/programs/:id", a.authMiddleware, a.RequirePermission("programs.delete"), h.DeleteProgram) + // File storage (MinIO) groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL) groupV1.Post("/files/upload", a.authMiddleware, h.UploadMedia) @@ -78,68 +85,6 @@ func (a *App) initAppRoutes() { groupV1.Get("/assessment/questions", h.ListAssessmentQuestions) groupV1.Get("/assessment/questions/:id", h.GetAssessmentQuestionByID) - // Unified Course Management (single hierarchy model) - groupV1.Get("/course-management/categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCourseCategories) - groupV1.Get("/course-management/categories/:categoryId/sub-categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCourseSubCategoriesByCategory) - groupV1.Get("/course-management/courses", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListAllCourses) - groupV1.Get("/course-management/human-language/courses", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListHumanLanguageCourses) - groupV1.Get("/course-management/sub-categories/:subCategoryId/courses", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCoursesBySubCategory) - groupV1.Get("/course-management/courses/:courseId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetCourseByID) - groupV1.Get("/course-management/levels", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListAllLevels) - groupV1.Get("/course-management/courses/:courseId/levels", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListLevelsByCourse) - groupV1.Get("/course-management/levels/:levelId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetLevelByID) - groupV1.Get("/course-management/modules", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListAllModules) - groupV1.Get("/course-management/levels/:levelId/modules", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListModulesByLevel) - groupV1.Get("/course-management/modules/:moduleId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetModuleByID) - groupV1.Get("/course-management/sub-modules", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListAllSubModules) - groupV1.Get("/course-management/modules/:moduleId/sub-modules", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListSubModulesByModule) - groupV1.Get("/course-management/sub-modules/:subModuleId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetSubModuleByID) - groupV1.Post("/course-management/categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseCategory) - groupV1.Delete("/course-management/categories/:categoryId", a.authMiddleware, a.RequirePermission("course_categories.delete"), h.DeleteCourseCategory) - groupV1.Post("/course-management/courses", a.authMiddleware, a.RequirePermission("courses.create"), h.CreateCourse) - groupV1.Put("/course-management/courses/:courseId", a.authMiddleware, a.RequirePermission("courses.update"), h.UpdateCourse) - groupV1.Delete("/course-management/courses/:courseId", a.authMiddleware, a.RequirePermission("courses.delete"), h.DeleteCourse) - groupV1.Post("/course-management/courses/:courseId/thumbnail", a.authMiddleware, a.RequirePermission("courses.upload_thumbnail"), h.UpdateCourseThumbnail) - groupV1.Get("/course-management/courses/:courseId/learning-path", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.CourseLearningPath) - groupV1.Get("/course-management/hierarchy", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.UnifiedHierarchy) - groupV1.Get("/course-management/human-language/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.Get("/course-management/sub-categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListCourseSubCategories) - groupV1.Get("/course-management/human-language/sub-categories", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.ListHumanLanguageCourseSubCategories) - groupV1.Post("/course-management/sub-categories", a.authMiddleware, a.RequirePermission("course_categories.create"), h.CreateCourseSubCategory) - groupV1.Delete("/course-management/sub-categories/:subCategoryId", a.authMiddleware, a.RequirePermission("course_categories.delete"), h.DeleteCourseSubCategory) - groupV1.Post("/course-management/levels", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateLevel) - groupV1.Put("/course-management/levels/:levelId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateLevel) - groupV1.Post("/course-management/modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateModule) - groupV1.Put("/course-management/modules/:moduleId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateModule) - groupV1.Delete("/course-management/modules/:moduleId", a.authMiddleware, a.RequirePermission("subcourses.delete"), h.DeleteModule) - groupV1.Post("/course-management/sub-modules", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubModule) - groupV1.Put("/course-management/sub-modules/:subModuleId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateSubModule) - groupV1.Delete("/course-management/sub-modules/:subModuleId", a.authMiddleware, a.RequirePermission("subcourses.delete"), h.DeleteSubModule) - groupV1.Post("/course-management/sub-module-videos", a.authMiddleware, a.RequirePermission("videos.create"), h.CreateSubModuleVideo) - groupV1.Put("/course-management/sub-module-videos/:videoId", a.authMiddleware, a.RequirePermission("videos.update"), h.UpdateSubModuleVideo) - groupV1.Delete("/course-management/sub-module-videos/:videoId", a.authMiddleware, a.RequirePermission("videos.delete"), h.DeleteSubModuleVideo) - groupV1.Get("/course-management/sub-modules/:subModuleId/lessons", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetSubModuleLessons) - groupV1.Get("/course-management/sub-module-lessons/:lessonId", a.authMiddleware, a.RequirePermission("learning_tree.get"), h.GetSubModuleLessonByID) - groupV1.Put("/course-management/sub-module-lessons/:lessonId", a.authMiddleware, a.RequirePermission("subcourses.update"), h.UpdateSubModuleLesson) - groupV1.Post("/course-management/sub-module-lessons", a.authMiddleware, a.RequirePermission("subcourses.create"), h.CreateSubModuleLesson) - groupV1.Get("/course-management/sub-modules/:subModuleId/practices", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetSubModulePractices) - groupV1.Get("/course-management/practices/:practiceId/detail", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetSubModulePracticeDetail) - groupV1.Get("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetSubModulePracticeByID) - groupV1.Post("/course-management/sub-module-practices", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateSubModulePractice) - groupV1.Put("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdatePractice) - groupV1.Delete("/course-management/practices/:practiceId", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeletePractice) - groupV1.Get("/course-management/sub-modules/:subModuleId/capstones", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetSubModuleCapstones) - groupV1.Get("/course-management/capstones/:capstoneId", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetSubModuleCapstoneByID) - groupV1.Post("/course-management/sub-module-capstones", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateSubModuleCapstone) - groupV1.Put("/course-management/capstones/:capstoneId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateSubModuleCapstone) - groupV1.Delete("/course-management/capstones/:capstoneId", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteCapstone) - groupV1.Get("/course-management/modules/:moduleId/capstones", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetModuleCapstones) - groupV1.Get("/course-management/module-capstones/:moduleCapstoneId", a.authMiddleware, a.RequirePermission("question_sets.get"), h.GetModuleCapstoneByID) - groupV1.Post("/course-management/module-capstones", a.authMiddleware, a.RequirePermission("question_sets.update"), h.CreateModuleCapstone) - groupV1.Put("/course-management/module-capstones/:moduleCapstoneId", a.authMiddleware, a.RequirePermission("question_sets.update"), h.UpdateModuleCapstone) - groupV1.Delete("/course-management/module-capstones/:moduleCapstoneId", a.authMiddleware, a.RequirePermission("question_sets.delete"), h.DeleteModuleCapstone) - // Questions groupV1.Post("/questions", a.authMiddleware, a.RequirePermission("questions.create"), h.CreateQuestion) groupV1.Get("/questions", a.authMiddleware, a.RequirePermission("questions.list"), h.ListQuestions) @@ -255,8 +200,6 @@ func (a *App) initAppRoutes() { groupV1.Delete("/user/me", a.authMiddleware, a.RequirePermission("users.delete_self"), h.DeleteMyUserAccount) groupV1.Post("/user/me/deletion/cancel", a.authMiddleware, a.RequirePermission("users.cancel_delete_self"), h.CancelMyUserAccountDeletion) groupV1.Post("/internal/users/purge-due-deletions", a.authMiddleware, a.RequirePermission("users.purge_due_deletions"), h.PurgeDueDeletedUsers) - groupV1.Post("/internal/db/reset-reseed", a.authMiddleware, a.RequirePermission("internal.db.reset_reseed"), h.ResetAndReseedDatabase) - groupV1.Post("/internal/db/clear-course-management", a.authMiddleware, a.RequirePermission("internal.db.reset_reseed"), h.ClearCourseManagementData) groupV1.Get("/user/single/:id", a.authMiddleware, a.RequirePermission("users.get"), h.GetUserByID) groupV1.Delete("/user/delete/:id", a.authMiddleware, a.RequirePermission("users.delete"), h.DeleteUser) groupV1.Post("/user/search", a.authMiddleware, a.RequirePermission("users.search"), h.SearchUserByNameOrPhone) @@ -343,8 +286,6 @@ 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) - // Legacy sub-course prerequisite/progression routes removed after hierarchy cutover. - // Ratings groupV1.Post("/ratings", a.authMiddleware, a.RequirePermission("ratings.submit"), h.SubmitRating) groupV1.Get("/ratings", a.authMiddleware, a.RequirePermission("ratings.list_by_target"), h.GetRatingsByTarget)