- Migration 000038 drops fixed A1-C3 check and widens cefr_level column - CreateLevel validates length and NUL only; preserve client casing - Regenerate Swagger docs Made-with: Cursor
2993 lines
109 KiB
Go
2993 lines
109 KiB
Go
package handlers
|
||
|
||
import (
|
||
dbgen "Yimaru-Backend/gen/db"
|
||
"Yimaru-Backend/internal/domain"
|
||
"strconv"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"github.com/gofiber/fiber/v2"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
)
|
||
|
||
type createCourseSubCategoryReq struct {
|
||
CategoryID int64 `json:"category_id"`
|
||
Name string `json:"name"`
|
||
Description *string `json:"description"`
|
||
DisplayOrder *int32 `json:"display_order"`
|
||
IsActive *bool `json:"is_active"`
|
||
}
|
||
|
||
type createCourseCategoryReq struct {
|
||
Name string `json:"name"`
|
||
IsActive *bool `json:"is_active"`
|
||
}
|
||
|
||
type createCourseReq struct {
|
||
CategoryID int64 `json:"category_id"`
|
||
SubCategoryID *int64 `json:"sub_category_id"`
|
||
Title string `json:"title"`
|
||
Description *string `json:"description"`
|
||
Thumbnail *string `json:"thumbnail"`
|
||
IntroVideoURL *string `json:"intro_video_url"`
|
||
IsActive *bool `json:"is_active"`
|
||
}
|
||
|
||
type updateCourseReq struct {
|
||
Title *string `json:"title"`
|
||
Description *string `json:"description"`
|
||
Thumbnail *string `json:"thumbnail"`
|
||
IntroVideoURL *string `json:"intro_video_url"`
|
||
IsActive *bool `json:"is_active"`
|
||
}
|
||
|
||
type updateCourseThumbnailReq struct {
|
||
ThumbnailURL string `json:"thumbnail_url"`
|
||
}
|
||
|
||
type createLevelReq struct {
|
||
CourseID int64 `json:"course_id"`
|
||
CEFRLevel string `json:"cefr_level"`
|
||
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,
|
||
},
|
||
})
|
||
}
|
||
|
||
// ListHumanLanguageCourseSubCategories godoc
|
||
// @Summary List Human Language sub-categories
|
||
// @Description Returns active sub-categories under Human Language category
|
||
// @Tags course-management
|
||
// @Produce json
|
||
// @Param offset query int false "Offset"
|
||
// @Param limit query int false "Limit"
|
||
// @Success 200 {object} domain.Response
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/human-language/sub-categories [get]
|
||
func (h *Handler) ListHumanLanguageCourseSubCategories(c *fiber.Ctx) error {
|
||
offset := int32(c.QueryInt("offset", 0))
|
||
if offset < 0 {
|
||
offset = 0
|
||
}
|
||
limit := int32(c.QueryInt("limit", 10000))
|
||
if limit <= 0 {
|
||
limit = 10000
|
||
}
|
||
|
||
rows, err := h.analyticsDB.GetHumanLanguageCourseSubCategories(c.Context(), dbgen.GetHumanLanguageCourseSubCategoriesParams{
|
||
Offset: pgtype.Int4{Int32: offset, Valid: true},
|
||
Limit: pgtype.Int4{Int32: limit, Valid: true},
|
||
})
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load Human Language sub-categories", Error: err.Error()})
|
||
}
|
||
|
||
total := 0
|
||
if len(rows) > 0 {
|
||
total = int(rows[0].TotalCount)
|
||
}
|
||
|
||
return c.JSON(domain.Response{
|
||
Message: "Human Language sub-categories retrieved successfully",
|
||
Data: map[string]interface{}{
|
||
"sub_categories": rows,
|
||
"total_count": total,
|
||
},
|
||
})
|
||
}
|
||
|
||
// CreateCourseCategory godoc
|
||
// @Summary Create course category
|
||
// @Description Legacy-compatible endpoint for creating a course category
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param body body createCourseCategoryReq true "Create category payload"
|
||
// @Success 201 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/categories [post]
|
||
func (h *Handler) CreateCourseCategory(c *fiber.Ctx) error {
|
||
var req createCourseCategoryReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||
}
|
||
if strings.TrimSpace(req.Name) == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "name is required"})
|
||
}
|
||
|
||
created, err := h.analyticsDB.CreateCourseCategory(c.Context(), dbgen.CreateCourseCategoryParams{
|
||
Name: req.Name,
|
||
Column2: boolOrNil(req.IsActive),
|
||
})
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create category", Error: err.Error()})
|
||
}
|
||
|
||
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Course category created", Data: created})
|
||
}
|
||
|
||
func (h *Handler) DeleteCourseCategory(c *fiber.Ctx) error {
|
||
categoryID, err := strconv.ParseInt(c.Params("categoryId"), 10, 64)
|
||
if err != nil || categoryID <= 0 {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid category ID", Error: "categoryId must be a positive integer"})
|
||
}
|
||
|
||
if err := h.analyticsDB.DeleteCourseCategoryCompat(c.Context(), categoryID); err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete category", Error: err.Error()})
|
||
}
|
||
|
||
return c.JSON(domain.Response{Message: "Course category deleted"})
|
||
}
|
||
|
||
// CreateCourse godoc
|
||
// @Summary Create course
|
||
// @Description Legacy-compatible endpoint for creating a course
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param body body createCourseReq true "Create course payload"
|
||
// @Success 201 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/courses [post]
|
||
func (h *Handler) CreateCourse(c *fiber.Ctx) error {
|
||
var req createCourseReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||
}
|
||
if req.CategoryID <= 0 || strings.TrimSpace(req.Title) == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "category_id and title are required"})
|
||
}
|
||
|
||
isActive := true
|
||
if req.IsActive != nil {
|
||
isActive = *req.IsActive
|
||
}
|
||
description := ""
|
||
if req.Description != nil {
|
||
description = *req.Description
|
||
}
|
||
thumbnail := ""
|
||
if req.Thumbnail != nil {
|
||
thumbnail = *req.Thumbnail
|
||
}
|
||
introVideoURL := ""
|
||
if req.IntroVideoURL != nil {
|
||
introVideoURL = *req.IntroVideoURL
|
||
}
|
||
|
||
created, err := h.analyticsDB.CreateCourseCompat(
|
||
c.Context(),
|
||
req.CategoryID,
|
||
req.SubCategoryID,
|
||
req.Title,
|
||
description,
|
||
thumbnail,
|
||
introVideoURL,
|
||
isActive,
|
||
)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create course", Error: err.Error()})
|
||
}
|
||
|
||
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Course created", Data: created})
|
||
}
|
||
|
||
// UpdateCourse godoc
|
||
// @Summary Update course
|
||
// @Description Legacy-compatible endpoint for updating a course
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param courseId path int true "Course ID"
|
||
// @Param body body updateCourseReq true "Update course payload"
|
||
// @Success 200 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/courses/{courseId} [put]
|
||
func (h *Handler) UpdateCourse(c *fiber.Ctx) error {
|
||
courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64)
|
||
if err != nil || courseID <= 0 {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: "courseId must be a positive integer"})
|
||
}
|
||
|
||
var req updateCourseReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||
}
|
||
|
||
existing, err := h.analyticsDB.GetCourseByID(c.Context(), courseID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course", Error: err.Error()})
|
||
}
|
||
|
||
title := existing.Title
|
||
if req.Title != nil {
|
||
title = *req.Title
|
||
}
|
||
description := existing.Description
|
||
if req.Description != nil {
|
||
description = toText(req.Description)
|
||
}
|
||
thumbnail := existing.Thumbnail
|
||
if req.Thumbnail != nil {
|
||
thumbnail = toText(req.Thumbnail)
|
||
}
|
||
introVideo := existing.IntroVideoUrl
|
||
if req.IntroVideoURL != nil {
|
||
introVideo = toText(req.IntroVideoURL)
|
||
}
|
||
isActive := existing.IsActive
|
||
if req.IsActive != nil {
|
||
isActive = *req.IsActive
|
||
}
|
||
|
||
if err := h.analyticsDB.UpdateCourse(c.Context(), dbgen.UpdateCourseParams{
|
||
Title: title,
|
||
Description: description,
|
||
Thumbnail: thumbnail,
|
||
IntroVideoUrl: introVideo,
|
||
IsActive: isActive,
|
||
ID: courseID,
|
||
}); err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update course", Error: err.Error()})
|
||
}
|
||
|
||
updated, err := h.analyticsDB.GetCourseByID(c.Context(), courseID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Course updated but failed to fetch latest record", Error: err.Error()})
|
||
}
|
||
|
||
return c.JSON(domain.Response{Message: "Course updated", Data: updated})
|
||
}
|
||
|
||
// DeleteCourse godoc
|
||
// @Summary Delete course
|
||
// @Description Legacy-compatible endpoint for deleting a course
|
||
// @Tags course-management
|
||
// @Produce json
|
||
// @Param courseId path int true "Course ID"
|
||
// @Success 200 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/courses/{courseId} [delete]
|
||
func (h *Handler) DeleteCourse(c *fiber.Ctx) error {
|
||
courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64)
|
||
if err != nil || courseID <= 0 {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: "courseId must be a positive integer"})
|
||
}
|
||
|
||
if err := h.analyticsDB.DeleteCourse(c.Context(), courseID); err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete course", Error: err.Error()})
|
||
}
|
||
|
||
return c.JSON(domain.Response{Message: "Course deleted"})
|
||
}
|
||
|
||
// UpdateCourseThumbnail godoc
|
||
// @Summary Update course thumbnail
|
||
// @Description Legacy-compatible endpoint for updating course thumbnail
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param courseId path int true "Course ID"
|
||
// @Param body body updateCourseThumbnailReq true "Update course thumbnail payload"
|
||
// @Success 200 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/courses/{courseId}/thumbnail [post]
|
||
func (h *Handler) UpdateCourseThumbnail(c *fiber.Ctx) error {
|
||
courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64)
|
||
if err != nil || courseID <= 0 {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: "courseId must be a positive integer"})
|
||
}
|
||
|
||
var req updateCourseThumbnailReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||
}
|
||
|
||
existing, err := h.analyticsDB.GetCourseByID(c.Context(), courseID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course", Error: err.Error()})
|
||
}
|
||
thumb := req.ThumbnailURL
|
||
if strings.TrimSpace(thumb) == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "thumbnail_url is required"})
|
||
}
|
||
|
||
if err := h.analyticsDB.UpdateCourse(c.Context(), dbgen.UpdateCourseParams{
|
||
Title: existing.Title,
|
||
Description: existing.Description,
|
||
Thumbnail: pgtype.Text{String: thumb, Valid: true},
|
||
IntroVideoUrl: existing.IntroVideoUrl,
|
||
IsActive: existing.IsActive,
|
||
ID: courseID,
|
||
}); err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update course thumbnail", Error: err.Error()})
|
||
}
|
||
|
||
updated, err := h.analyticsDB.GetCourseByID(c.Context(), courseID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Thumbnail updated but failed to fetch latest record", Error: err.Error()})
|
||
}
|
||
|
||
return c.JSON(domain.Response{Message: "Course thumbnail updated", Data: updated})
|
||
}
|
||
|
||
// UnifiedHierarchy godoc
|
||
// @Summary Get unified course hierarchy
|
||
// @Description Returns full hierarchy: category -> sub-category -> course
|
||
// @Tags course-management
|
||
// @Produce json
|
||
// @Success 200 {object} domain.Response
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/hierarchy [get]
|
||
func (h *Handler) UnifiedHierarchy(c *fiber.Ctx) error {
|
||
rows, err := h.analyticsDB.GetCoursesWithHierarchy(c.Context())
|
||
if err != nil {
|
||
if isMissingCourseSubCategoryTableErr(err) {
|
||
legacyRows, legacyErr := h.buildLegacyHierarchyRows(c)
|
||
if legacyErr != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load hierarchy", Error: legacyErr.Error()})
|
||
}
|
||
return c.JSON(domain.Response{Message: "Unified hierarchy retrieved successfully", Data: legacyRows})
|
||
}
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load hierarchy", Error: err.Error()})
|
||
}
|
||
return c.JSON(domain.Response{Message: "Unified hierarchy retrieved successfully", Data: rows})
|
||
}
|
||
|
||
// UnifiedHierarchyByCourse godoc
|
||
// @Summary Get hierarchy for a course
|
||
// @Description Returns hierarchy nodes for one course including levels/modules/sub-modules
|
||
// @Tags course-management
|
||
// @Produce json
|
||
// @Param courseId path int true "Course ID"
|
||
// @Success 200 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/courses/{courseId}/hierarchy [get]
|
||
func (h *Handler) UnifiedHierarchyByCourse(c *fiber.Ctx) error {
|
||
courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: err.Error()})
|
||
}
|
||
rows, err := h.analyticsDB.GetFullHierarchyByCourseID(c.Context(), courseID)
|
||
if err != nil {
|
||
if isMissingCourseSubCategoryTableErr(err) {
|
||
course, getCourseErr := h.analyticsDB.GetCourseByID(c.Context(), courseID)
|
||
if getCourseErr != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course hierarchy", Error: getCourseErr.Error()})
|
||
}
|
||
return c.JSON(domain.Response{
|
||
Message: "Course hierarchy retrieved successfully",
|
||
Data: []map[string]interface{}{
|
||
{
|
||
"course_id": course.ID,
|
||
"course_title": course.Title,
|
||
"level_id": nil,
|
||
"cefr_level": nil,
|
||
"level_title": nil,
|
||
"level_description": nil,
|
||
"level_thumbnail": nil,
|
||
"module_id": nil,
|
||
"module_title": nil,
|
||
"module_icon_url": nil,
|
||
"sub_module_id": nil,
|
||
"sub_module_title": nil,
|
||
"sub_module_description": nil,
|
||
"sub_module_thumbnail": nil,
|
||
"sub_module_tips": nil,
|
||
"sub_module_display_order": nil,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course hierarchy", Error: err.Error()})
|
||
}
|
||
return c.JSON(domain.Response{Message: "Course hierarchy retrieved successfully", Data: rows})
|
||
}
|
||
|
||
// CourseLearningPath godoc
|
||
// @Summary Get course learning path
|
||
// @Description Legacy-compatible endpoint for course learning path
|
||
// @Tags course-management
|
||
// @Produce json
|
||
// @Param courseId path int true "Course ID"
|
||
// @Success 200 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/courses/{courseId}/learning-path [get]
|
||
func (h *Handler) CourseLearningPath(c *fiber.Ctx) error {
|
||
courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64)
|
||
if err != nil || courseID <= 0 {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: "courseId must be a positive integer"})
|
||
}
|
||
|
||
course, err := h.analyticsDB.GetCourseByID(c.Context(), courseID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course", Error: err.Error()})
|
||
}
|
||
category, err := h.analyticsDB.GetCourseCategoryByID(c.Context(), course.CategoryID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course category", Error: err.Error()})
|
||
}
|
||
rows, err := h.analyticsDB.GetFullHierarchyByCourseID(c.Context(), courseID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course learning path", Error: err.Error()})
|
||
}
|
||
|
||
subCourseByID := map[int64]*domain.LearningPathSubCourse{}
|
||
subCourseOrder := make([]int64, 0)
|
||
for _, row := range rows {
|
||
if !row.SubModuleID.Valid {
|
||
continue
|
||
}
|
||
subModuleID := row.SubModuleID.Int64
|
||
if _, exists := subCourseByID[subModuleID]; exists {
|
||
continue
|
||
}
|
||
title := ""
|
||
if row.SubModuleTitle.Valid {
|
||
title = row.SubModuleTitle.String
|
||
}
|
||
level := ""
|
||
if row.LevelTitle.Valid && strings.TrimSpace(row.LevelTitle.String) != "" {
|
||
level = strings.TrimSpace(row.LevelTitle.String)
|
||
} else if row.CefrLevel.Valid {
|
||
level = row.CefrLevel.String
|
||
}
|
||
displayOrder := int32(len(subCourseOrder))
|
||
if row.SubModuleDisplayOrder.Valid {
|
||
displayOrder = row.SubModuleDisplayOrder.Int32
|
||
}
|
||
subCourseByID[subModuleID] = &domain.LearningPathSubCourse{
|
||
ID: subModuleID,
|
||
Title: title,
|
||
Description: textPtr(row.SubModuleDescription),
|
||
Thumbnail: textPtr(row.SubModuleThumbnail),
|
||
Tips: textPtr(row.SubModuleTips),
|
||
DisplayOrder: displayOrder,
|
||
Level: level,
|
||
SubLevel: level,
|
||
PrerequisiteCount: 0,
|
||
Prerequisites: []domain.LearningPathPrerequisite{},
|
||
Videos: []domain.LearningPathVideo{},
|
||
Practices: []domain.LearningPathPractice{},
|
||
}
|
||
subCourseOrder = append(subCourseOrder, subModuleID)
|
||
}
|
||
|
||
for _, subModuleID := range subCourseOrder {
|
||
node := subCourseByID[subModuleID]
|
||
videos, videoErr := h.analyticsDB.GetSubModuleVideos(c.Context(), subModuleID)
|
||
if videoErr != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load sub-module videos", Error: videoErr.Error()})
|
||
}
|
||
for _, v := range videos {
|
||
node.Videos = append(node.Videos, domain.LearningPathVideo{
|
||
ID: v.ID,
|
||
Title: v.Title,
|
||
Description: textPtr(v.Description),
|
||
VideoURL: v.VideoUrl,
|
||
Duration: int32(v.Duration.Int32),
|
||
Resolution: textPtr(v.Resolution),
|
||
DisplayOrder: v.DisplayOrder,
|
||
VimeoID: textPtr(v.VimeoID),
|
||
VimeoEmbedURL: textPtr(v.VimeoEmbedUrl),
|
||
VideoHostProvider: textPtr(v.VideoHostProvider),
|
||
})
|
||
}
|
||
node.VideoCount = int64(len(node.Videos))
|
||
|
||
practices, practiceErr := h.analyticsDB.GetSubModulePractices(c.Context(), subModuleID)
|
||
if practiceErr != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load sub-module practices", Error: practiceErr.Error()})
|
||
}
|
||
for _, p := range practices {
|
||
node.Practices = append(node.Practices, domain.LearningPathPractice{
|
||
ID: p.ID,
|
||
Title: p.Title,
|
||
Description: textPtr(p.Description),
|
||
Status: p.Status,
|
||
IntroVideoURL: textPtr(p.IntroVideoUrl),
|
||
QuestionCount: p.QuestionCount,
|
||
})
|
||
}
|
||
node.PracticeCount = int64(len(node.Practices))
|
||
}
|
||
|
||
subCourses := make([]domain.LearningPathSubCourse, 0, len(subCourseOrder))
|
||
for _, id := range subCourseOrder {
|
||
subCourses = append(subCourses, *subCourseByID[id])
|
||
}
|
||
|
||
path := domain.LearningPath{
|
||
CourseID: course.ID,
|
||
CourseTitle: course.Title,
|
||
Description: textPtr(course.Description),
|
||
Thumbnail: textPtr(course.Thumbnail),
|
||
IntroVideoURL: textPtr(course.IntroVideoUrl),
|
||
CategoryID: category.ID,
|
||
CategoryName: category.Name,
|
||
SubCourses: subCourses,
|
||
}
|
||
|
||
return c.JSON(domain.Response{Message: "Course learning path retrieved successfully", Data: path})
|
||
}
|
||
|
||
func isMissingCourseSubCategoryTableErr(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
return strings.Contains(strings.ToLower(err.Error()), "relation \"course_sub_categories\" does not exist")
|
||
}
|
||
|
||
func (h *Handler) buildLegacyHierarchyRows(c *fiber.Ctx) ([]legacyHierarchyRow, error) {
|
||
categories, err := h.analyticsDB.GetAllCourseCategories(c.Context(), dbgen.GetAllCourseCategoriesParams{
|
||
Offset: pgtype.Int4{Int32: 0, Valid: true},
|
||
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
out := make([]legacyHierarchyRow, 0, len(categories))
|
||
for _, cat := range categories {
|
||
courses, courseErr := h.analyticsDB.GetCoursesByCategory(c.Context(), dbgen.GetCoursesByCategoryParams{
|
||
CategoryID: cat.ID,
|
||
Offset: pgtype.Int4{Int32: 0, Valid: true},
|
||
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||
})
|
||
if courseErr != nil {
|
||
return nil, courseErr
|
||
}
|
||
|
||
if len(courses) == 0 {
|
||
out = append(out, legacyHierarchyRow{
|
||
CategoryID: cat.ID,
|
||
CategoryName: cat.Name,
|
||
SubCategoryID: nil,
|
||
SubCategoryName: nil,
|
||
CourseID: nil,
|
||
CourseTitle: nil,
|
||
})
|
||
continue
|
||
}
|
||
|
||
for _, course := range courses {
|
||
courseID := course.ID
|
||
courseTitle := course.Title
|
||
out = append(out, legacyHierarchyRow{
|
||
CategoryID: cat.ID,
|
||
CategoryName: cat.Name,
|
||
SubCategoryID: nil,
|
||
SubCategoryName: nil,
|
||
CourseID: &courseID,
|
||
CourseTitle: &courseTitle,
|
||
})
|
||
}
|
||
}
|
||
|
||
return out, nil
|
||
}
|
||
|
||
// CreateCourseSubCategory godoc
|
||
// @Summary Create course sub-category
|
||
// @Description Creates a sub-category under a course category
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param body body createCourseSubCategoryReq true "Create sub-category payload"
|
||
// @Success 201 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/sub-categories [post]
|
||
func (h *Handler) CreateCourseSubCategory(c *fiber.Ctx) error {
|
||
var req createCourseSubCategoryReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||
}
|
||
if req.CategoryID <= 0 || strings.TrimSpace(req.Name) == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "category_id and name are required"})
|
||
}
|
||
created, err := h.analyticsDB.CreateCourseSubCategory(c.Context(), dbgen.CreateCourseSubCategoryParams{
|
||
CategoryID: req.CategoryID,
|
||
Name: req.Name,
|
||
Description: toText(req.Description),
|
||
Column4: intOrNil(req.DisplayOrder),
|
||
Column5: boolOrNil(req.IsActive),
|
||
})
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-category", Error: err.Error()})
|
||
}
|
||
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Course sub-category created", Data: created})
|
||
}
|
||
|
||
func (h *Handler) DeleteCourseSubCategory(c *fiber.Ctx) error {
|
||
subCategoryID, err := strconv.ParseInt(c.Params("subCategoryId"), 10, 64)
|
||
if err != nil || subCategoryID <= 0 {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid sub-category ID", Error: "subCategoryId must be a positive integer"})
|
||
}
|
||
|
||
if err := h.analyticsDB.DeleteCourseSubCategoryCompat(c.Context(), subCategoryID); err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete sub-category", Error: err.Error()})
|
||
}
|
||
|
||
return c.JSON(domain.Response{Message: "Course sub-category deleted"})
|
||
}
|
||
|
||
// CreateLevel godoc
|
||
// @Summary Create level
|
||
// @Description Creates a level under a course. cefr_level is a short level code or label (1–64 characters), unique per course; optional title defaults to that value; optional description and thumbnail
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param body body createLevelReq true "Create level payload"
|
||
// @Success 201 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/levels [post]
|
||
func (h *Handler) CreateLevel(c *fiber.Ctx) error {
|
||
var req createLevelReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||
}
|
||
cefr := strings.TrimSpace(req.CEFRLevel)
|
||
if req.CourseID <= 0 || cefr == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "course_id and cefr_level are required"})
|
||
}
|
||
if strings.Contains(cefr, "\x00") {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "cefr_level must not contain NUL characters"})
|
||
}
|
||
const maxCefrLevelRunes = 64
|
||
if utf8.RuneCountInString(cefr) > maxCefrLevelRunes {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "cefr_level must be at most 64 characters"})
|
||
}
|
||
title := cefr
|
||
if req.Title != nil {
|
||
if t := strings.TrimSpace(*req.Title); t != "" {
|
||
title = t
|
||
}
|
||
}
|
||
created, err := h.analyticsDB.CreateLevel(c.Context(), dbgen.CreateLevelParams{
|
||
CourseID: req.CourseID,
|
||
CefrLevel: cefr,
|
||
Title: title,
|
||
Description: toText(req.Description),
|
||
Thumbnail: toText(req.Thumbnail),
|
||
Column6: intOrNil(req.DisplayOrder),
|
||
Column7: boolOrNil(req.IsActive),
|
||
})
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create level", Error: err.Error()})
|
||
}
|
||
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Level created", Data: created})
|
||
}
|
||
|
||
// UpdateLevel godoc
|
||
// @Summary Update level
|
||
// @Description Updates level title, description, thumbnail, display order, and active flag
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param levelId path int true "Level ID"
|
||
// @Param body body updateLevelReq true "Update level payload"
|
||
// @Success 200 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 404 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/levels/{levelId} [put]
|
||
func (h *Handler) UpdateLevel(c *fiber.Ctx) error {
|
||
levelID, err := strconv.ParseInt(c.Params("levelId"), 10, 64)
|
||
if err != nil || levelID <= 0 {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid level ID", Error: "levelId must be a positive integer"})
|
||
}
|
||
var req updateLevelReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||
}
|
||
|
||
current, err := h.analyticsDB.GetLevelByID(c.Context(), levelID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Level not found", Error: err.Error()})
|
||
}
|
||
|
||
targetTitle := current.Title
|
||
if req.Title != nil {
|
||
t := strings.TrimSpace(*req.Title)
|
||
if t == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"})
|
||
}
|
||
targetTitle = t
|
||
}
|
||
|
||
targetDescription := mergeTextField(current.Description, req.Description)
|
||
targetThumbnail := mergeTextField(current.Thumbnail, req.Thumbnail)
|
||
|
||
targetDisplayOrder := current.DisplayOrder
|
||
if req.DisplayOrder != nil {
|
||
targetDisplayOrder = *req.DisplayOrder
|
||
}
|
||
|
||
targetIsActive := current.IsActive
|
||
if req.IsActive != nil {
|
||
targetIsActive = *req.IsActive
|
||
}
|
||
|
||
updated, err := h.analyticsDB.UpdateLevel(c.Context(), dbgen.UpdateLevelParams{
|
||
Title: targetTitle,
|
||
Description: targetDescription,
|
||
Thumbnail: targetThumbnail,
|
||
DisplayOrder: targetDisplayOrder,
|
||
IsActive: targetIsActive,
|
||
ID: levelID,
|
||
})
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update level", Error: err.Error()})
|
||
}
|
||
|
||
return c.JSON(domain.Response{Message: "Level updated", Data: updated})
|
||
}
|
||
|
||
// CreateModule godoc
|
||
// @Summary Create module
|
||
// @Description Creates a module under a level; optional icon_url stores a module icon image URL
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param body body createModuleReq true "Create module payload"
|
||
// @Success 201 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/modules [post]
|
||
func (h *Handler) CreateModule(c *fiber.Ctx) error {
|
||
var req createModuleReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||
}
|
||
if req.LevelID <= 0 || strings.TrimSpace(req.Title) == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "level_id and title are required"})
|
||
}
|
||
created, err := h.analyticsDB.CreateModule(c.Context(), dbgen.CreateModuleParams{
|
||
LevelID: req.LevelID,
|
||
Title: req.Title,
|
||
Description: toText(req.Description),
|
||
IconUrl: toText(req.IconURL),
|
||
Column5: intOrNil(req.DisplayOrder),
|
||
Column6: boolOrNil(req.IsActive),
|
||
})
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create module", Error: err.Error()})
|
||
}
|
||
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Module created", Data: created})
|
||
}
|
||
|
||
// UpdateModule godoc
|
||
// @Summary Update module
|
||
// @Description Updates module title, description, icon URL, display order, and active flag
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param moduleId path int true "Module ID"
|
||
// @Param body body updateModuleReq true "Update module payload"
|
||
// @Success 200 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 404 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/modules/{moduleId} [put]
|
||
func (h *Handler) UpdateModule(c *fiber.Ctx) error {
|
||
moduleID, err := strconv.ParseInt(c.Params("moduleId"), 10, 64)
|
||
if err != nil || moduleID <= 0 {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid module ID", Error: "moduleId must be a positive integer"})
|
||
}
|
||
var req updateModuleReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||
}
|
||
|
||
current, err := h.analyticsDB.GetModuleByID(c.Context(), moduleID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Module not found", Error: err.Error()})
|
||
}
|
||
|
||
targetTitle := current.Title
|
||
if req.Title != nil {
|
||
t := strings.TrimSpace(*req.Title)
|
||
if t == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "title cannot be empty"})
|
||
}
|
||
targetTitle = t
|
||
}
|
||
targetDesc := mergeTextField(current.Description, req.Description)
|
||
targetIcon := mergeTextField(current.IconUrl, req.IconURL)
|
||
targetOrder := current.DisplayOrder
|
||
if req.DisplayOrder != nil {
|
||
targetOrder = *req.DisplayOrder
|
||
}
|
||
targetActive := current.IsActive
|
||
if req.IsActive != nil {
|
||
targetActive = *req.IsActive
|
||
}
|
||
|
||
updated, err := h.analyticsDB.UpdateModule(c.Context(), dbgen.UpdateModuleParams{
|
||
Title: targetTitle,
|
||
Description: targetDesc,
|
||
IconUrl: targetIcon,
|
||
DisplayOrder: targetOrder,
|
||
IsActive: targetActive,
|
||
ID: moduleID,
|
||
})
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update module", Error: err.Error()})
|
||
}
|
||
return c.JSON(domain.Response{Message: "Module updated", Data: updated})
|
||
}
|
||
|
||
// CreateSubModule godoc
|
||
// @Summary Create sub-module
|
||
// @Description Creates a sub-module under a module; optional thumbnail (image URL) and tips text
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param body body createSubModuleReq true "Create sub-module payload"
|
||
// @Success 201 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/sub-modules [post]
|
||
func (h *Handler) CreateSubModule(c *fiber.Ctx) error {
|
||
var req createSubModuleReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||
}
|
||
if req.ModuleID <= 0 || strings.TrimSpace(req.Title) == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "module_id and title are required"})
|
||
}
|
||
created, err := h.analyticsDB.CreateSubModule(c.Context(), dbgen.CreateSubModuleParams{
|
||
ModuleID: req.ModuleID,
|
||
Title: req.Title,
|
||
Description: toText(req.Description),
|
||
Thumbnail: toText(req.Thumbnail),
|
||
Tips: toText(req.Tips),
|
||
Column6: intOrNil(req.DisplayOrder),
|
||
Column7: boolOrNil(req.IsActive),
|
||
})
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module", Error: err.Error()})
|
||
}
|
||
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module created", Data: created})
|
||
}
|
||
|
||
// CreateSubModuleVideo godoc
|
||
// @Summary Create sub-module video
|
||
// @Description Creates a video under a sub-module
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param body body createSubModuleVideoReq true "Create sub-module video payload"
|
||
// @Success 201 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/sub-module-videos [post]
|
||
func (h *Handler) CreateSubModuleVideo(c *fiber.Ctx) error {
|
||
var req createSubModuleVideoReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||
}
|
||
if req.SubModuleID <= 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.VideoURL) == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id, title and video_url are required"})
|
||
}
|
||
created, err := h.analyticsDB.CreateSubModuleVideo(c.Context(), dbgen.CreateSubModuleVideoParams{
|
||
SubModuleID: req.SubModuleID,
|
||
Title: req.Title,
|
||
Description: toText(req.Description),
|
||
VideoUrl: req.VideoURL,
|
||
Duration: toInt4(req.Duration),
|
||
Resolution: toText(req.Resolution),
|
||
Column7: nil,
|
||
Visibility: toText(req.Visibility),
|
||
InstructorID: toText(req.InstructorID),
|
||
Thumbnail: toText(req.Thumbnail),
|
||
Column12: intOrNil(req.DisplayOrder),
|
||
Column13: req.Status,
|
||
VimeoID: pgtype.Text{Valid: false},
|
||
VimeoEmbedUrl: pgtype.Text{Valid: false},
|
||
VimeoPlayerHtml: pgtype.Text{Valid: false},
|
||
VimeoStatus: pgtype.Text{Valid: false},
|
||
Column18: nil,
|
||
})
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module video", Error: err.Error()})
|
||
}
|
||
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module video created", Data: created})
|
||
}
|
||
|
||
// CreateSubModuleLesson godoc
|
||
// @Summary Create lesson under sub-module
|
||
// @Description Creates a sub-module lesson with teaching content (text, image, audio, video URLs) and optional thumbnail
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param body body createSubModuleLessonReq true "Create lesson payload"
|
||
// @Success 201 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/sub-module-lessons [post]
|
||
func (h *Handler) CreateSubModuleLesson(c *fiber.Ctx) error {
|
||
var req createSubModuleLessonReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
|
||
}
|
||
if req.SubModuleID <= 0 || strings.TrimSpace(req.Title) == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id and title are required"})
|
||
}
|
||
created, err := h.analyticsDB.CreateSubModuleLesson(c.Context(), dbgen.CreateSubModuleLessonParams{
|
||
SubModuleID: req.SubModuleID,
|
||
Title: strings.TrimSpace(req.Title),
|
||
Description: toText(req.Description),
|
||
Thumbnail: toText(req.Thumbnail),
|
||
TeachingText: toText(req.TeachingText),
|
||
TeachingImageUrl: toText(req.TeachingImageURL),
|
||
TeachingAudioUrl: toText(req.TeachingAudioURL),
|
||
TeachingVideoUrl: toText(req.TeachingVideoURL),
|
||
Column9: intOrNil(req.DisplayOrder),
|
||
Column10: boolOrNil(req.IsActive),
|
||
})
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create lesson", Error: err.Error()})
|
||
}
|
||
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Lesson created", Data: created})
|
||
}
|
||
|
||
// GetSubModuleLessons godoc
|
||
// @Summary Get lessons under sub-module
|
||
// @Description Returns all active lessons for a sub-module (teaching content metadata)
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param subModuleId path int true "Sub-module ID"
|
||
// @Success 200 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/sub-modules/{subModuleId}/lessons [get]
|
||
func (h *Handler) GetSubModuleLessons(c *fiber.Ctx) error {
|
||
subModuleID, err := strconv.ParseInt(c.Params("subModuleId"), 10, 64)
|
||
if err != nil || subModuleID <= 0 {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||
Message: "Invalid sub-module ID",
|
||
Error: "subModuleId must be a valid positive integer",
|
||
})
|
||
}
|
||
|
||
lessons, err := h.analyticsDB.GetSubModuleLessons(c.Context(), subModuleID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||
Message: "Failed to get sub-module lessons",
|
||
Error: err.Error(),
|
||
})
|
||
}
|
||
|
||
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||
Message: "Sub-module lessons retrieved successfully",
|
||
Data: lessons,
|
||
})
|
||
}
|
||
|
||
// GetSubModuleLessonByID godoc
|
||
// @Summary Get lesson detail
|
||
// @Description Returns one active lesson detail by lesson ID
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param lessonId path int true "Lesson ID"
|
||
// @Success 200 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 404 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/sub-module-lessons/{lessonId} [get]
|
||
func (h *Handler) GetSubModuleLessonByID(c *fiber.Ctx) error {
|
||
lessonID, err := strconv.ParseInt(c.Params("lessonId"), 10, 64)
|
||
if err != nil || lessonID <= 0 {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||
Message: "Invalid lesson ID",
|
||
Error: "lessonId must be a valid positive integer",
|
||
})
|
||
}
|
||
|
||
lesson, err := h.analyticsDB.GetSubModuleLessonByID(c.Context(), lessonID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||
Message: "Lesson not found",
|
||
Error: err.Error(),
|
||
})
|
||
}
|
||
|
||
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||
Message: "Lesson detail retrieved successfully",
|
||
Data: lesson,
|
||
})
|
||
}
|
||
|
||
// UpdateSubModuleLesson godoc
|
||
// @Summary Update lesson detail
|
||
// @Description Updates lesson 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
|
||
}
|
||
|
||
if _, 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,
|
||
}); err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||
Message: "Failed to update lesson",
|
||
Error: err.Error(),
|
||
})
|
||
}
|
||
|
||
updatedLesson, err := h.analyticsDB.GetSubModuleLessonByID(c.Context(), lessonID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||
Message: "Lesson updated but failed to fetch latest detail",
|
||
Error: err.Error(),
|
||
})
|
||
}
|
||
|
||
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(),
|
||
})
|
||
}
|
||
|
||
return c.JSON(domain.Response{
|
||
Message: "Sub-module practices retrieved successfully",
|
||
Data: map[string]interface{}{
|
||
"practices": practices,
|
||
"total_count": len(practices),
|
||
},
|
||
})
|
||
}
|
||
|
||
// GetSubModulePracticeByID godoc
|
||
// @Summary Get practice detail
|
||
// @Description Returns one active practice by practice ID
|
||
// @Tags course-management
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param practiceId path int true "Practice ID"
|
||
// @Success 200 {object} domain.Response
|
||
// @Failure 400 {object} domain.ErrorResponse
|
||
// @Failure 404 {object} domain.ErrorResponse
|
||
// @Failure 500 {object} domain.ErrorResponse
|
||
// @Router /api/v1/course-management/practices/{practiceId} [get]
|
||
func (h *Handler) GetSubModulePracticeByID(c *fiber.Ctx) error {
|
||
practiceID, err := strconv.ParseInt(c.Params("practiceId"), 10, 64)
|
||
if err != nil || practiceID <= 0 {
|
||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||
Message: "Invalid practice ID",
|
||
Error: "practiceId must be a positive integer",
|
||
})
|
||
}
|
||
|
||
practice, err := h.analyticsDB.GetSubModulePracticeByID(c.Context(), practiceID)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||
Message: "Practice not found",
|
||
Error: err.Error(),
|
||
})
|
||
}
|
||
|
||
return c.JSON(domain.Response{
|
||
Message: "Practice retrieved successfully",
|
||
Data: practice,
|
||
})
|
||
}
|
||
|
||
// 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"})
|
||
}
|
||
|