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