Yimaru-BackEnd/internal/web_server/handlers/hierarchy_handler.go
Yared Yemane d9783310d1 Add legacy hierarchy fallback for pre-migration databases.
Handle missing course_sub_categories table by serving hierarchy data from legacy categories/courses queries so content pages keep loading until unified hierarchy migration is applied.

Made-with: Cursor
2026-04-14 01:02:31 -07:00

476 lines
18 KiB
Go

package handlers
import (
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5/pgtype"
)
type createCourseSubCategoryReq struct {
CategoryID int64 `json:"category_id"`
Name string `json:"name"`
Description *string `json:"description"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createLevelReq struct {
CourseID int64 `json:"course_id"`
CEFRLevel string `json:"cefr_level"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createModuleReq struct {
LevelID int64 `json:"level_id"`
Title string `json:"title"`
Description *string `json:"description"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createSubModuleReq struct {
ModuleID int64 `json:"module_id"`
Title string `json:"title"`
Description *string `json:"description"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createSubModuleVideoReq struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description *string `json:"description"`
VideoURL string `json:"video_url"`
Duration *int32 `json:"duration"`
Resolution *string `json:"resolution"`
Visibility *string `json:"visibility"`
InstructorID *string `json:"instructor_id"`
Thumbnail *string `json:"thumbnail"`
DisplayOrder *int32 `json:"display_order"`
Status *string `json:"status"`
}
type attachSubModuleLessonReq struct {
SubModuleID int64 `json:"sub_module_id"`
QuestionSetID int64 `json:"question_set_id"`
IntroVideoURL *string `json:"intro_video_url"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type createSubModulePracticeReq struct {
SubModuleID int64 `json:"sub_module_id"`
Title string `json:"title"`
Description *string `json:"description"`
Thumbnail *string `json:"thumbnail"`
IntroVideoURL *string `json:"intro_video_url"`
QuestionSetID int64 `json:"question_set_id"`
DisplayOrder *int32 `json:"display_order"`
IsActive *bool `json:"is_active"`
}
type legacyHierarchyRow struct {
CategoryID int64 `json:"category_id"`
CategoryName string `json:"category_name"`
SubCategoryID *int64 `json:"sub_category_id"`
SubCategoryName *string `json:"sub_category_name"`
CourseID *int64 `json:"course_id"`
CourseTitle *string `json:"course_title"`
}
func toText(v *string) pgtype.Text {
if v == nil {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: *v, Valid: true}
}
func toInt4(v *int32) pgtype.Int4 {
if v == nil {
return pgtype.Int4{Valid: false}
}
return pgtype.Int4{Int32: *v, Valid: true}
}
func boolOrNil(v *bool) interface{} {
if v == nil {
return nil
}
return *v
}
func intOrNil(v *int32) interface{} {
if v == nil {
return nil
}
return *v
}
// UnifiedHierarchy godoc
// @Summary Get unified course hierarchy
// @Description Returns full hierarchy: category -> sub-category -> course
// @Tags course-management
// @Produce json
// @Success 200 {object} domain.Response
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/hierarchy [get]
func (h *Handler) UnifiedHierarchy(c *fiber.Ctx) error {
rows, err := h.analyticsDB.GetCoursesWithHierarchy(c.Context())
if err != nil {
if isMissingCourseSubCategoryTableErr(err) {
legacyRows, legacyErr := h.buildLegacyHierarchyRows(c)
if legacyErr != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load hierarchy", Error: legacyErr.Error()})
}
return c.JSON(domain.Response{Message: "Unified hierarchy retrieved successfully", Data: legacyRows})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load hierarchy", Error: err.Error()})
}
return c.JSON(domain.Response{Message: "Unified hierarchy retrieved successfully", Data: rows})
}
// UnifiedHierarchyByCourse godoc
// @Summary Get hierarchy for a course
// @Description Returns hierarchy nodes for one course including levels/modules/sub-modules
// @Tags course-management
// @Produce json
// @Param courseId path int true "Course ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/courses/{courseId}/hierarchy [get]
func (h *Handler) UnifiedHierarchyByCourse(c *fiber.Ctx) error {
courseID, err := strconv.ParseInt(c.Params("courseId"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid course ID", Error: err.Error()})
}
rows, err := h.analyticsDB.GetFullHierarchyByCourseID(c.Context(), courseID)
if err != nil {
if isMissingCourseSubCategoryTableErr(err) {
course, getCourseErr := h.analyticsDB.GetCourseByID(c.Context(), courseID)
if getCourseErr != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course hierarchy", Error: getCourseErr.Error()})
}
return c.JSON(domain.Response{
Message: "Course hierarchy retrieved successfully",
Data: []map[string]interface{}{
{
"course_id": course.ID,
"course_title": course.Title,
"level_id": nil,
"cefr_level": nil,
"module_id": nil,
"module_title": nil,
"sub_module_id": nil,
"sub_module_title": nil,
},
},
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load course hierarchy", Error: err.Error()})
}
return c.JSON(domain.Response{Message: "Course hierarchy retrieved successfully", Data: rows})
}
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})
}
// CreateLevel godoc
// @Summary Create level
// @Description Creates a CEFR level under a course
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createLevelReq true "Create level payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/levels [post]
func (h *Handler) CreateLevel(c *fiber.Ctx) error {
var req createLevelReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
req.CEFRLevel = strings.ToUpper(strings.TrimSpace(req.CEFRLevel))
validCEFR := map[string]bool{"A1": true, "A2": true, "A3": true, "B1": true, "B2": true, "B3": true, "C1": true, "C2": true, "C3": true}
if req.CourseID <= 0 || !validCEFR[req.CEFRLevel] {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "course_id and valid cefr_level are required"})
}
created, err := h.analyticsDB.CreateLevel(c.Context(), dbgen.CreateLevelParams{
CourseID: req.CourseID,
CefrLevel: req.CEFRLevel,
Column3: intOrNil(req.DisplayOrder),
Column4: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create level", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Level created", Data: created})
}
// CreateModule godoc
// @Summary Create module
// @Description Creates a module under a level
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createModuleReq true "Create module payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/modules [post]
func (h *Handler) CreateModule(c *fiber.Ctx) error {
var req createModuleReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.LevelID <= 0 || strings.TrimSpace(req.Title) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "level_id and title are required"})
}
created, err := h.analyticsDB.CreateModule(c.Context(), dbgen.CreateModuleParams{
LevelID: req.LevelID,
Title: req.Title,
Description: toText(req.Description),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create module", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Module created", Data: created})
}
// CreateSubModule godoc
// @Summary Create sub-module
// @Description Creates a sub-module under a module
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createSubModuleReq true "Create sub-module payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-modules [post]
func (h *Handler) CreateSubModule(c *fiber.Ctx) error {
var req createSubModuleReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.ModuleID <= 0 || strings.TrimSpace(req.Title) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "module_id and title are required"})
}
created, err := h.analyticsDB.CreateSubModule(c.Context(), dbgen.CreateSubModuleParams{
ModuleID: req.ModuleID,
Title: req.Title,
Description: toText(req.Description),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module created", Data: created})
}
// CreateSubModuleVideo godoc
// @Summary Create sub-module video
// @Description Creates a video under a sub-module
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body createSubModuleVideoReq true "Create sub-module video payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-module-videos [post]
func (h *Handler) CreateSubModuleVideo(c *fiber.Ctx) error {
var req createSubModuleVideoReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.SubModuleID <= 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.VideoURL) == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id, title and video_url are required"})
}
created, err := h.analyticsDB.CreateSubModuleVideo(c.Context(), dbgen.CreateSubModuleVideoParams{
SubModuleID: req.SubModuleID,
Title: req.Title,
Description: toText(req.Description),
VideoUrl: req.VideoURL,
Duration: toInt4(req.Duration),
Resolution: toText(req.Resolution),
Column7: nil,
Visibility: toText(req.Visibility),
InstructorID: toText(req.InstructorID),
Thumbnail: toText(req.Thumbnail),
Column12: intOrNil(req.DisplayOrder),
Column13: req.Status,
VimeoID: pgtype.Text{Valid: false},
VimeoEmbedUrl: pgtype.Text{Valid: false},
VimeoPlayerHtml: pgtype.Text{Valid: false},
VimeoStatus: pgtype.Text{Valid: false},
Column18: nil,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create sub-module video", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Sub-module video created", Data: created})
}
// AttachSubModuleLesson godoc
// @Summary Attach lesson to sub-module
// @Description Links a question set lesson to a sub-module
// @Tags course-management
// @Accept json
// @Produce json
// @Param body body attachSubModuleLessonReq true "Attach lesson payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/course-management/sub-module-lessons [post]
func (h *Handler) AttachSubModuleLesson(c *fiber.Ctx) error {
var req attachSubModuleLessonReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
if req.SubModuleID <= 0 || req.QuestionSetID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "sub_module_id and question_set_id are required"})
}
attached, err := h.analyticsDB.AttachQuestionSetLessonToSubModule(c.Context(), dbgen.AttachQuestionSetLessonToSubModuleParams{
SubModuleID: req.SubModuleID,
QuestionSetID: req.QuestionSetID,
IntroVideoUrl: toText(req.IntroVideoURL),
Column4: intOrNil(req.DisplayOrder),
Column5: boolOrNil(req.IsActive),
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to attach lesson", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{Message: "Lesson attached to sub-module", Data: attached})
}
// 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})
}