Yimaru-BackEnd/internal/web_server/handlers/hierarchy_handler.go
Yared Yemane 6839d1aa0d fix: sub-module practices list excludes non-PRACTICE sets and bad Response flags
- Drop question_sets.set_type = PRACTICE filter so sub_module_practices rows list correctly
- Set Success and StatusCode on GET sub-modules/:id/practices response
- Return empty JSON array instead of null for no practices

Made-with: Cursor
2026-04-21 09:31:22 -07:00

3119 lines
113 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handlers
import (
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 (164 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),
},
})
}
// 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,
})
}
// GetSubModulePracticeDetail godoc
// @Summary Get practice with full question list
// @Description Returns one active practice with question-set fields and the ordered question list (full item detail)
// @Tags course-management
// @Produce json
// @Param practiceId path int true "Practice ID (sub_module_practices.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.analyticsDB.GetSubModulePracticeByID(c.Context(), practiceID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Practice not found",
Error: err.Error(),
})
}
const pageSize int32 = 500
var allItems []domain.QuestionSetItemWithQuestion
var offset int32
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(),
})
}
allItems = append(allItems, batch...)
if int64(len(allItems)) >= total || len(batch) == 0 {
break
}
offset += pageSize
}
return c.JSON(domain.Response{
Message: "Practice retrieved successfully",
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"})
}