Yimaru-BackEnd/internal/web_server/handlers/hierarchy_handler.go
Yared Yemane 1026354c24 Expand course hierarchy read APIs and practice retrieval.
Add list/detail endpoints for courses, levels, modules, submodules, and submodule practices; extend course listing queries; add lesson update support and clean up removed route paths.

Made-with: Cursor
2026-04-17 07:52:22 -07:00

2129 lines
74 KiB
Go

package handlers
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5/pgtype"
)
type createCourseSubCategoryReq struct {
CategoryID int64 `json:"category_id"`
Name string `json:"name"`
Description *string `json:"description"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type 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"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createModuleReq struct {
LevelID int64 `json:"level_id"`
Title string `json:"title"`
Description *string `json:"description"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createSubModuleReq struct {
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Description *string `json:"description"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createSubModuleVideoReq struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description *string `json:"description"`
VideoURL string `json:"video_url"`
Duration *int32 `json:"duration"`
Resolution *string `json:"resolution"`
Visibility *string `json:"visibility"`
InstructorID *string `json:"instructor_id"`
Thumbnail *string `json:"thumbnail"`
DisplayOrder *int32 `json:"display_order"`
Status *string `json:"status"`
}
type updateSubModuleReq struct {
Title *string `json:"title"`
Description *string `json:"description"`
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 attachSubModuleLessonReq struct {
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoURL *string `json:"intro_video_url"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type updateLessonQuestionReq struct {
QuestionID int64 `json:"question_id"`
DisplayOrder *int32 `json:"display_order"`
}
type updateSubModuleLessonReq struct {
SubModuleID *int64 `json:"sub_module_id"`
QuestionSetID *int64 `json:"question_set_id"`
IntroVideoURL *string `json:"intro_video_url"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
Title *string `json:"title"`
Description *string `json:"description"`
BannerImage *string `json:"banner_image"`
Persona *string `json:"persona"`
TimeLimitMinutes *int32 `json:"time_limit_minutes"`
PassingScore *int32 `json:"passing_score"`
ShuffleQuestions *bool `json:"shuffle_questions"`
Status *string `json:"status"`
SubCourseVideoID *int64 `json:"sub_course_video_id"`
Questions []updateLessonQuestionReq `json:"questions"`
}
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 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 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,
},
})
}
// 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,
"module_id": nil,
"module_title": nil,
"sub_module_id": nil,
"sub_module_title": 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.CefrLevel.Valid {
level = row.CefrLevel.String
}
subCourseByID[subModuleID] = &domain.LearningPathSubCourse{
ID: subModuleID,
Title: title,
DisplayOrder: int32(len(subCourseOrder)),
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 CEFR level under a course
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createLevelReq true "Create level payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/levels [post]
func (h *Handler) CreateLevel(c *fiber.Ctx) error {
var req createLevelReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
req.CEFRLevel = strings.ToUpper(strings.TrimSpace(req.CEFRLevel))
validCEFR := map[string]bool{"A1": true, "A2": true, "A3": true, "B1": true, "B2": true, "B3": true, "C1": true, "C2": true, "C3": true}
if req.CourseID <= 0 || !validCEFR[req.CEFRLevel] {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "course_id and valid cefr_level are required"})
}
created, err := h.analyticsDB.CreateLevel(c.Context(), dbgen.CreateLevelParams{
CourseID: req.CourseID,
CefrLevel: req.CEFRLevel,
Column3: intOrNil(req.DisplayOrder),
Column4: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create level", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Level created", Data: created})
}
// CreateModule godoc
// @Summary Create module
// @Description Creates a module under a level
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createModuleReq true "Create module payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/modules [post]
func (h *Handler) CreateModule(c *fiber.Ctx) error {
var req createModuleReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.LevelID <= 0 || strings.TrimSpace(req.Title) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "level_id and title are required"})
}
created, err := h.analyticsDB.CreateModule(c.Context(), dbgen.CreateModuleParams{
LevelID: req.LevelID,
Title: req.Title,
Description: toText(req.Description),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create module", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Module created", Data: created})
}
// CreateSubModule godoc
// @Summary Create sub-module
// @Description Creates a sub-module under a module
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createSubModuleReq true "Create sub-module payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-modules [post]
func (h *Handler) CreateSubModule(c *fiber.Ctx) error {
var req createSubModuleReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.ModuleID <= 0 || strings.TrimSpace(req.Title) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "module_id and title are required"})
}
created, err := h.analyticsDB.CreateSubModule(c.Context(), dbgen.CreateSubModuleParams{
ModuleID: req.ModuleID,
Title: req.Title,
Description: toText(req.Description),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module created", Data: created})
}
// CreateSubModuleVideo godoc
// @Summary Create sub-module video
// @Description Creates a video under a sub-module
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createSubModuleVideoReq true "Create sub-module video payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-module-videos [post]
func (h *Handler) CreateSubModuleVideo(c *fiber.Ctx) error {
var req createSubModuleVideoReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.SubModuleID <= 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.VideoURL) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id, title and video_url are required"})
}
created, err := h.analyticsDB.CreateSubModuleVideo(c.Context(), dbgen.CreateSubModuleVideoParams{
SubModuleID: req.SubModuleID,
Title: req.Title,
Description: toText(req.Description),
VideoUrl: req.VideoURL,
Duration: toInt4(req.Duration),
Resolution: toText(req.Resolution),
Column7: nil,
Visibility: toText(req.Visibility),
InstructorID: toText(req.InstructorID),
Thumbnail: toText(req.Thumbnail),
Column12: intOrNil(req.DisplayOrder),
Column13: req.Status,
VimeoID: pgtype.Text{Valid: false},
VimeoEmbedUrl: pgtype.Text{Valid: false},
VimeoPlayerHtml: pgtype.Text{Valid: false},
VimeoStatus: pgtype.Text{Valid: false},
Column18: nil,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module video", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module video created", Data: created})
}
// AttachSubModuleLesson godoc
// @Summary Attach lesson to sub-module
// @Description Links a question set lesson to a sub-module
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body attachSubModuleLessonReq true "Attach lesson payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-module-lessons [post]
func (h *Handler) AttachSubModuleLesson(c *fiber.Ctx) error {
var req attachSubModuleLessonReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.SubModuleID <= 0 || req.QuestionSetID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id and question_set_id are required"})
}
attached, err := h.analyticsDB.AttachQuestionSetLessonToSubModule(c.Context(), dbgen.AttachQuestionSetLessonToSubModuleParams{
SubModuleID: req.SubModuleID,
QuestionSetID: req.QuestionSetID,
IntroVideoUrl: toText(req.IntroVideoURL),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to attach lesson", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Lesson attached to sub-module", Data: attached})
}
// GetSubModuleLessons godoc
// @Summary Get lessons under sub-module
// @Description Returns all active lessons attached to a sub-module with question-set details
// @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}/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",
})
}
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 active lesson detail by lesson ID
// @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 metadata, linked question-set metadata, and optionally replaces lesson questions
// @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
}
targetQuestionSetID := currentLesson.QuestionSetID
if req.QuestionSetID != nil {
if *req.QuestionSetID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "question_set_id must be a positive integer"})
}
targetQuestionSetID = *req.QuestionSetID
}
targetIntroVideoURL := currentLesson.IntroVideoUrl
if req.IntroVideoURL != nil {
targetIntroVideoURL = toText(req.IntroVideoURL)
}
targetDisplayOrder := currentLesson.DisplayOrder
if req.DisplayOrder != nil {
targetDisplayOrder = *req.DisplayOrder
}
targetIsActive := currentLesson.IsActive
if req.IsActive != nil {
targetIsActive = *req.IsActive
}
if _, err := h.analyticsDB.UpdateSubModuleLesson(c.Context(), dbgen.UpdateSubModuleLessonParams{
SubModuleID: targetSubModuleID,
QuestionSetID: targetQuestionSetID,
IntroVideoUrl: targetIntroVideoURL,
DisplayOrder: targetDisplayOrder,
IsActive: targetIsActive,
ID: lessonID,
}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update lesson",
Error: err.Error(),
})
}
currentSet, err := h.questionsSvc.GetQuestionSetByID(c.Context(), targetQuestionSetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load linked question set",
Error: err.Error(),
})
}
shouldUpdateSet := req.Title != nil || req.Description != nil || req.BannerImage != nil ||
req.Persona != nil || req.TimeLimitMinutes != nil || req.PassingScore != nil ||
req.ShuffleQuestions != nil || req.Status != nil || req.SubCourseVideoID != nil
if shouldUpdateSet {
title := currentSet.Title
if req.Title != nil {
title = *req.Title
}
input := domain.CreateQuestionSetInput{
Title: title,
Description: currentSet.Description,
BannerImage: currentSet.BannerImage,
Persona: currentSet.Persona,
TimeLimitMinutes: currentSet.TimeLimitMinutes,
PassingScore: currentSet.PassingScore,
SubCourseVideoID: currentSet.SubCourseVideoID,
IntroVideoURL: req.IntroVideoURL,
ShuffleQuestions: &currentSet.ShuffleQuestions,
}
currentStatus := currentSet.Status
input.Status = &currentStatus
if req.Description != nil {
input.Description = req.Description
}
if req.BannerImage != nil {
input.BannerImage = req.BannerImage
}
if req.Persona != nil {
input.Persona = req.Persona
}
if req.TimeLimitMinutes != nil {
input.TimeLimitMinutes = req.TimeLimitMinutes
}
if req.PassingScore != nil {
input.PassingScore = req.PassingScore
}
if req.ShuffleQuestions != nil {
input.ShuffleQuestions = req.ShuffleQuestions
}
if req.Status != nil {
input.Status = req.Status
}
if req.SubCourseVideoID != nil {
input.SubCourseVideoID = req.SubCourseVideoID
}
if err := h.questionsSvc.UpdateQuestionSet(c.Context(), targetQuestionSetID, input); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update linked 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{}{}
order := q.DisplayOrder
if order == nil {
defaultOrder := int32(idx)
order = &defaultOrder
}
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(),
})
}
if _, err := h.questionsSvc.AddQuestionToSet(c.Context(), targetQuestionSetID, q.QuestionID, order); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to upsert lesson question",
Error: err.Error(),
})
}
}
existingItems, err := h.questionsSvc.GetQuestionSetItems(c.Context(), targetQuestionSetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load existing lesson questions",
Error: err.Error(),
})
}
for _, item := range existingItems {
if _, keep := seen[item.QuestionID]; keep {
continue
}
if err := h.questionsSvc.RemoveQuestionFromSet(c.Context(), targetQuestionSetID, item.QuestionID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to remove question from lesson",
Error: err.Error(),
})
}
}
}
updatedLesson, err := h.analyticsDB.GetSubModuleLessonByID(c.Context(), lessonID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Lesson updated but failed to fetch latest detail",
Error: err.Error(),
})
}
updatedQuestions, err := h.questionsSvc.GetQuestionSetItems(c.Context(), updatedLesson.QuestionSetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Lesson updated but failed to fetch latest questions",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Lesson updated successfully",
Data: map[string]interface{}{
"lesson": updatedLesson,
"questions": updatedQuestions,
},
})
}
// 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})
}
// 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(),
})
}
return c.JSON(domain.Response{
Message: "Sub-module practices retrieved successfully",
Data: map[string]interface{}{
"practices": practices,
"total_count": len(practices),
},
})
}
// GetSubModulePracticeByID godoc
// @Summary Get practice detail
// @Description Returns one active practice by practice ID
// @Tags course-management
// @Accept json
// @Produce json
// @Param practiceId path int true "Practice 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.analyticsDB.GetSubModulePracticeByID(c.Context(), practiceID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Practice retrieved successfully",
Data: practice,
})
}
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"})
}
existing, err := h.analyticsDB.GetSubModuleByIDCompat(c.Context(), subModuleID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load sub-module", 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()})
}
title := existing.Title
if req.Title != nil {
title = *req.Title
}
description := ""
if existing.Description.Valid {
description = existing.Description.String
}
if req.Description != nil {
description = *req.Description
}
isActive := existing.IsActive
if req.IsActive != nil {
isActive = *req.IsActive
}
if err := h.analyticsDB.UpdateSubModuleCompat(c.Context(), subModuleID, title, description, isActive); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update sub-module", Error: err.Error()})
}
updated, err := h.analyticsDB.GetSubModuleByIDCompat(c.Context(), subModuleID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Sub-module updated but failed to fetch latest record", 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"})
}