package handlers import ( "Yimaru-Backend/internal/domain" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "github.com/gofiber/fiber/v2" "github.com/google/uuid" "go.uber.org/zap" ) // Course Category Handlers type createCourseCategoryReq struct { Name string `json:"name" validate:"required"` } type courseCategoryRes struct { ID int64 `json:"id"` Name string `json:"name"` IsActive bool `json:"is_active"` CreatedAt string `json:"created_at"` } // CreateCourseCategory godoc // @Summary Create a new course category // @Description Creates a new course category with the provided name // @Tags course-categories // @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(), }) } category, err := h.courseMgmtSvc.CreateCourseCategory(c.Context(), req.Name) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to create course category", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"name": category.Name}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryCreated, domain.ResourceCategory, &category.ID, "Created course category: "+category.Name, meta, &ip, &ua) return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Course category created successfully", Data: courseCategoryRes{ ID: category.ID, Name: category.Name, IsActive: category.IsActive, CreatedAt: category.CreatedAt.String(), }, }) } // GetCourseCategoryByID godoc // @Summary Get course category by ID // @Description Returns a single course category by its ID // @Tags course-categories // @Produce json // @Param id path int true "Category 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/categories/{id} [get] func (h *Handler) GetCourseCategoryByID(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid category ID", Error: err.Error(), }) } category, err := h.courseMgmtSvc.GetCourseCategoryByID(c.Context(), id) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Course category not found", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Course category retrieved successfully", Data: courseCategoryRes{ ID: category.ID, Name: category.Name, IsActive: category.IsActive, CreatedAt: category.CreatedAt.String(), }, }) } type getAllCourseCategoriesRes struct { Categories []courseCategoryRes `json:"categories"` TotalCount int64 `json:"total_count"` } // GetAllCourseCategories godoc // @Summary Get all course categories // @Description Returns a paginated list of all course categories // @Tags course-categories // @Produce json // @Param limit query int false "Limit" default(10) // @Param offset query int false "Offset" default(0) // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/categories [get] func (h *Handler) GetAllCourseCategories(c *fiber.Ctx) error { limitStr := c.Query("limit", "10") offsetStr := c.Query("offset", "0") limit, err := strconv.Atoi(limitStr) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid limit parameter", Error: err.Error(), }) } offset, err := strconv.Atoi(offsetStr) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid offset parameter", Error: err.Error(), }) } categories, totalCount, err := h.courseMgmtSvc.GetAllCourseCategories(c.Context(), int32(limit), int32(offset)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to retrieve course categories", Error: err.Error(), }) } var categoryResponses []courseCategoryRes for _, category := range categories { categoryResponses = append(categoryResponses, courseCategoryRes{ ID: category.ID, Name: category.Name, IsActive: category.IsActive, CreatedAt: category.CreatedAt.String(), }) } return c.JSON(domain.Response{ Message: "Course categories retrieved successfully", Data: getAllCourseCategoriesRes{ Categories: categoryResponses, TotalCount: totalCount, }, }) } type updateCourseCategoryReq struct { Name *string `json:"name"` IsActive *bool `json:"is_active"` } // UpdateCourseCategory godoc // @Summary Update course category // @Description Updates a course category's name and/or active status // @Tags course-categories // @Accept json // @Produce json // @Param id path int true "Category ID" // @Param body body updateCourseCategoryReq true "Update category payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/categories/{id} [put] func (h *Handler) UpdateCourseCategory(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid category ID", Error: err.Error(), }) } var req updateCourseCategoryReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } err = h.courseMgmtSvc.UpdateCourseCategory(c.Context(), id, req.Name, req.IsActive) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update course category", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name, "is_active": req.IsActive}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryUpdated, domain.ResourceCategory, &id, fmt.Sprintf("Updated course category ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Course category updated successfully", }) } // DeleteCourseCategory godoc // @Summary Delete course category // @Description Deletes a course category by its ID // @Tags course-categories // @Produce json // @Param id path int true "Category ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/categories/{id} [delete] func (h *Handler) DeleteCourseCategory(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid category ID", Error: err.Error(), }) } err = h.courseMgmtSvc.DeleteCourseCategory(c.Context(), id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to delete course category", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"id": id}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryDeleted, domain.ResourceCategory, &id, fmt.Sprintf("Deleted category ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Course category deleted successfully", }) } // Course Handlers type createCourseReq struct { CategoryID int64 `json:"category_id" validate:"required"` Title string `json:"title" validate:"required"` Description *string `json:"description"` Thumbnail *string `json:"thumbnail"` IntroVideoURL *string `json:"intro_video_url"` } type courseRes struct { ID int64 `json:"id"` CategoryID int64 `json:"category_id"` Title string `json:"title"` Description *string `json:"description"` Thumbnail *string `json:"thumbnail"` IntroVideoURL *string `json:"intro_video_url,omitempty"` IsActive bool `json:"is_active"` } // CreateCourse godoc // @Summary Create a new course // @Description Creates a new course under a specific category // @Tags courses // @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(), }) } course, err := h.courseMgmtSvc.CreateCourse(c.Context(), req.CategoryID, req.Title, req.Description, req.Thumbnail, req.IntroVideoURL) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to create course", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"title": course.Title, "category_id": course.CategoryID}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseCreated, domain.ResourceCourse, &course.ID, "Created course: "+course.Title, meta, &ip, &ua) go func() { students, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: string(domain.RoleStudent)}) if err != nil { return } for _, s := range students { h.sendInAppNotification(s.ID, domain.NOTIFICATION_TYPE_COURSE_CREATED, "New Course Available", "A new course \""+course.Title+"\" has been added. Check it out!") } }() return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Course created successfully", Data: courseRes{ ID: course.ID, CategoryID: course.CategoryID, Title: course.Title, Description: course.Description, Thumbnail: course.Thumbnail, IntroVideoURL: course.IntroVideoURL, IsActive: course.IsActive, }, }) } // GetCourseByID godoc // @Summary Get course by ID // @Description Returns a single course by its ID // @Tags courses // @Produce json // @Param id 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/{id} [get] func (h *Handler) GetCourseByID(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid course ID", Error: err.Error(), }) } course, err := h.courseMgmtSvc.GetCourseByID(c.Context(), id) 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: courseRes{ ID: course.ID, CategoryID: course.CategoryID, Title: course.Title, Description: course.Description, Thumbnail: course.Thumbnail, IntroVideoURL: course.IntroVideoURL, IsActive: course.IsActive, }, }) } type getCoursesByCategoryRes struct { Courses []courseRes `json:"courses"` TotalCount int64 `json:"total_count"` } // GetCoursesByCategory godoc // @Summary Get courses by category // @Description Returns a paginated list of courses under a specific category // @Tags courses // @Produce json // @Param categoryId path int true "Category ID" // @Param limit query int false "Limit" default(10) // @Param offset query int false "Offset" default(0) // @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) GetCoursesByCategory(c *fiber.Ctx) error { categoryIDStr := c.Params("categoryId") categoryID, err := strconv.ParseInt(categoryIDStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid category ID", Error: err.Error(), }) } limitStr := c.Query("limit", "10") offsetStr := c.Query("offset", "0") limit, err := strconv.Atoi(limitStr) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid limit parameter", Error: err.Error(), }) } offset, err := strconv.Atoi(offsetStr) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid offset parameter", Error: err.Error(), }) } courses, totalCount, err := h.courseMgmtSvc.GetCoursesByCategory(c.Context(), categoryID, int32(limit), int32(offset)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to retrieve courses", Error: err.Error(), }) } var courseResponses []courseRes for _, course := range courses { courseResponses = append(courseResponses, courseRes{ ID: course.ID, CategoryID: course.CategoryID, Title: course.Title, Description: course.Description, Thumbnail: course.Thumbnail, IntroVideoURL: course.IntroVideoURL, IsActive: course.IsActive, }) } return c.JSON(domain.Response{ Message: "Courses retrieved successfully", Data: getCoursesByCategoryRes{ Courses: courseResponses, TotalCount: totalCount, }, }) } 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"` } // UpdateCourse godoc // @Summary Update course // @Description Updates a course's title, description, and/or active status // @Tags courses // @Accept json // @Produce json // @Param id 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/{id} [put] func (h *Handler) UpdateCourse(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid course ID", Error: err.Error(), }) } 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(), }) } err = h.courseMgmtSvc.UpdateCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.IntroVideoURL, req.IsActive) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update course", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"id": id, "title": req.Title, "description": req.Description, "thumbnail": req.Thumbnail, "is_active": req.IsActive}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceCourse, &id, fmt.Sprintf("Updated course ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Course updated successfully", }) } // DeleteCourse godoc // @Summary Delete course // @Description Deletes a course by its ID // @Tags courses // @Produce json // @Param id 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/{id} [delete] func (h *Handler) DeleteCourse(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid course ID", Error: err.Error(), }) } err = h.courseMgmtSvc.DeleteCourse(c.Context(), id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to delete course", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"id": id}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseDeleted, domain.ResourceCourse, &id, fmt.Sprintf("Deleted course ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Course deleted successfully", }) } // Sub-course Handlers type createSubCourseReq struct { CourseID int64 `json:"course_id" validate:"required"` Title string `json:"title" validate:"required"` Description *string `json:"description"` Thumbnail *string `json:"thumbnail"` DisplayOrder *int32 `json:"display_order"` Level string `json:"level" validate:"required"` // BEGINNER, INTERMEDIATE, ADVANCED SubLevel string `json:"sub_level" validate:"required"` // A1..C3 depending on level } type subCourseRes struct { ID int64 `json:"id"` CourseID int64 `json:"course_id"` Title string `json:"title"` Description *string `json:"description"` Thumbnail *string `json:"thumbnail"` DisplayOrder int32 `json:"display_order"` Level string `json:"level"` SubLevel string `json:"sub_level"` IsActive bool `json:"is_active"` } func isValidSubLevelForLevel(level, subLevel string) bool { switch strings.ToUpper(level) { case string(domain.SubCourseLevelBeginner): return subLevel == string(domain.SubCourseSubLevelA1) || subLevel == string(domain.SubCourseSubLevelA2) || subLevel == string(domain.SubCourseSubLevelA3) case string(domain.SubCourseLevelIntermediate): return subLevel == string(domain.SubCourseSubLevelB1) || subLevel == string(domain.SubCourseSubLevelB2) || subLevel == string(domain.SubCourseSubLevelB3) case string(domain.SubCourseLevelAdvanced): return subLevel == string(domain.SubCourseSubLevelC1) || subLevel == string(domain.SubCourseSubLevelC2) || subLevel == string(domain.SubCourseSubLevelC3) default: return false } } // CreateSubCourse godoc // @Summary Create a new sub-course // @Description Creates a new sub-course under a specific course // @Tags sub-courses // @Accept json // @Produce json // @Param body body createSubCourseReq true "Create sub-course payload" // @Success 201 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/sub-courses [post] func (h *Handler) CreateSubCourse(c *fiber.Ctx) error { var req createSubCourseReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } if !isValidSubLevelForLevel(req.Level, req.SubLevel) { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid sub_level for the selected level", Error: "BEGINNER requires A1/A2/A3, INTERMEDIATE requires B1/B2/B3, ADVANCED requires C1/C2/C3", }) } subCourse, err := h.courseMgmtSvc.CreateSubCourse(c.Context(), req.CourseID, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.Level, req.SubLevel) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to create sub-course", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"title": subCourse.Title, "course_id": subCourse.CourseID, "level": subCourse.Level, "sub_level": subCourse.SubLevel}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseCreated, domain.ResourceSubCourse, &subCourse.ID, "Created sub-course: "+subCourse.Title, meta, &ip, &ua) go func() { students, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: string(domain.RoleStudent)}) if err != nil { return } for _, s := range students { h.sendInAppNotification(s.ID, domain.NOTIFICATION_TYPE_SUB_COURSE_CREATED, "New Content Available", "A new sub-course \""+subCourse.Title+"\" has been added.") } }() return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Sub-course created successfully", Data: subCourseRes{ ID: subCourse.ID, CourseID: subCourse.CourseID, Title: subCourse.Title, Description: subCourse.Description, Thumbnail: subCourse.Thumbnail, DisplayOrder: subCourse.DisplayOrder, Level: subCourse.Level, SubLevel: subCourse.SubLevel, IsActive: subCourse.IsActive, }, }) } // GetSubCourseByID godoc // @Summary Get sub-course by ID // @Description Returns a single sub-course by its ID // @Tags sub-courses // @Produce json // @Param id path int true "Sub-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/sub-courses/{id} [get] func (h *Handler) GetSubCourseByID(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid sub-course ID", Error: err.Error(), }) } subCourse, err := h.courseMgmtSvc.GetSubCourseByID(c.Context(), id) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Sub-course not found", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Sub-course retrieved successfully", Data: subCourseRes{ ID: subCourse.ID, CourseID: subCourse.CourseID, Title: subCourse.Title, Description: subCourse.Description, Thumbnail: subCourse.Thumbnail, DisplayOrder: subCourse.DisplayOrder, Level: subCourse.Level, SubLevel: subCourse.SubLevel, IsActive: subCourse.IsActive, }, }) } type getSubCoursesByCourseRes struct { SubCourses []subCourseRes `json:"sub_courses"` TotalCount int64 `json:"total_count"` } // GetSubCoursesByCourse godoc // @Summary Get sub-courses by course // @Description Returns all sub-courses under a specific course // @Tags sub-courses // @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}/sub-courses [get] func (h *Handler) GetSubCoursesByCourse(c *fiber.Ctx) error { courseIDStr := c.Params("courseId") courseID, err := strconv.ParseInt(courseIDStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid course ID", Error: err.Error(), }) } subCourses, totalCount, err := h.courseMgmtSvc.GetSubCoursesByCourse(c.Context(), courseID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to retrieve sub-courses", Error: err.Error(), }) } var subCourseResponses []subCourseRes for _, sc := range subCourses { subCourseResponses = append(subCourseResponses, subCourseRes{ ID: sc.ID, CourseID: sc.CourseID, Title: sc.Title, Description: sc.Description, Thumbnail: sc.Thumbnail, DisplayOrder: sc.DisplayOrder, Level: sc.Level, SubLevel: sc.SubLevel, IsActive: sc.IsActive, }) } return c.JSON(domain.Response{ Message: "Sub-courses retrieved successfully", Data: getSubCoursesByCourseRes{ SubCourses: subCourseResponses, TotalCount: totalCount, }, }) } // ListSubCoursesByCourse godoc // @Summary List active sub-courses by course // @Description Returns a list of active sub-courses under a specific course // @Tags sub-courses // @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}/sub-courses/list [get] func (h *Handler) ListSubCoursesByCourse(c *fiber.Ctx) error { courseIDStr := c.Params("courseId") courseID, err := strconv.ParseInt(courseIDStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid course ID", Error: err.Error(), }) } subCourses, err := h.courseMgmtSvc.ListSubCoursesByCourse(c.Context(), courseID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to retrieve sub-courses", Error: err.Error(), }) } var subCourseResponses []subCourseRes for _, sc := range subCourses { subCourseResponses = append(subCourseResponses, subCourseRes{ ID: sc.ID, CourseID: sc.CourseID, Title: sc.Title, Description: sc.Description, Thumbnail: sc.Thumbnail, DisplayOrder: sc.DisplayOrder, Level: sc.Level, SubLevel: sc.SubLevel, IsActive: sc.IsActive, }) } return c.JSON(domain.Response{ Message: "Sub-courses retrieved successfully", Data: subCourseResponses, }) } // ListActiveSubCourses godoc // @Summary List all active sub-courses // @Description Returns a list of all active sub-courses // @Tags sub-courses // @Produce json // @Success 200 {object} domain.Response // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/sub-courses/active [get] func (h *Handler) ListActiveSubCourses(c *fiber.Ctx) error { subCourses, err := h.courseMgmtSvc.ListActiveSubCourses(c.Context()) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to retrieve active sub-courses", Error: err.Error(), }) } var subCourseResponses []subCourseRes for _, sc := range subCourses { subCourseResponses = append(subCourseResponses, subCourseRes{ ID: sc.ID, CourseID: sc.CourseID, Title: sc.Title, Description: sc.Description, Thumbnail: sc.Thumbnail, DisplayOrder: sc.DisplayOrder, Level: sc.Level, SubLevel: sc.SubLevel, IsActive: sc.IsActive, }) } return c.JSON(domain.Response{ Message: "Active sub-courses retrieved successfully", Data: subCourseResponses, }) } type updateSubCourseReq struct { Title *string `json:"title"` Description *string `json:"description"` Thumbnail *string `json:"thumbnail"` DisplayOrder *int32 `json:"display_order"` Level *string `json:"level"` SubLevel *string `json:"sub_level"` IsActive *bool `json:"is_active"` } // UpdateSubCourse godoc // @Summary Update sub-course // @Description Updates a sub-course's fields // @Tags sub-courses // @Accept json // @Produce json // @Param id path int true "Sub-course ID" // @Param body body updateSubCourseReq true "Update sub-course payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/sub-courses/{id} [patch] func (h *Handler) UpdateSubCourse(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid sub-course ID", Error: err.Error(), }) } var req updateSubCourseReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } if req.Level != nil || req.SubLevel != nil { existing, getErr := h.courseMgmtSvc.GetSubCourseByID(c.Context(), id) if getErr != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Sub-course not found", Error: getErr.Error(), }) } level := existing.Level subLevel := existing.SubLevel if req.Level != nil { level = *req.Level } if req.SubLevel != nil { subLevel = *req.SubLevel } if !isValidSubLevelForLevel(level, subLevel) { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid sub_level for the selected level", Error: "BEGINNER requires A1/A2/A3, INTERMEDIATE requires B1/B2/B3, ADVANCED requires C1/C2/C3", }) } } err = h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, req.Title, req.Description, req.Thumbnail, req.DisplayOrder, req.Level, req.SubLevel, req.IsActive) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update sub-course", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"id": id, "title": req.Title, "description": req.Description, "level": req.Level, "sub_level": req.SubLevel, "is_active": req.IsActive}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseUpdated, domain.ResourceSubCourse, &id, fmt.Sprintf("Updated sub-course ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Sub-course updated successfully", }) } // DeactivateSubCourse godoc // @Summary Deactivate sub-course // @Description Deactivates a sub-course by its ID // @Tags sub-courses // @Produce json // @Param id path int true "Sub-course ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/sub-courses/{id}/deactivate [put] func (h *Handler) DeactivateSubCourse(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid sub-course ID", Error: err.Error(), }) } err = h.courseMgmtSvc.DeactivateSubCourse(c.Context(), id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to deactivate sub-course", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"id": id}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseDeactivated, domain.ResourceSubCourse, &id, fmt.Sprintf("Deactivated sub-course ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Sub-course deactivated successfully", }) } // DeleteSubCourse godoc // @Summary Delete sub-course // @Description Deletes a sub-course by its ID // @Tags sub-courses // @Produce json // @Param id path int true "Sub-course ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/sub-courses/{id} [delete] func (h *Handler) DeleteSubCourse(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid sub-course ID", Error: err.Error(), }) } _, err = h.courseMgmtSvc.DeleteSubCourse(c.Context(), id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to delete sub-course", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"id": id}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseDeleted, domain.ResourceSubCourse, &id, fmt.Sprintf("Deleted sub-course ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Sub-course deleted successfully", }) } // Sub-course Video Handlers type createSubCourseVideoReq struct { SubCourseID int64 `json:"sub_course_id" validate:"required"` Title string `json:"title" validate:"required"` Description *string `json:"description"` VideoURL string `json:"video_url" validate:"required"` Duration int32 `json:"duration" validate:"required"` Resolution *string `json:"resolution"` InstructorID *string `json:"instructor_id"` Thumbnail *string `json:"thumbnail"` Visibility *string `json:"visibility"` DisplayOrder *int32 `json:"display_order"` Status *string `json:"status"` // DRAFT, PUBLISHED, INACTIVE, ARCHIVED } type subCourseVideoRes struct { ID int64 `json:"id"` SubCourseID int64 `json:"sub_course_id"` Title string `json:"title"` Description *string `json:"description"` VideoURL string `json:"video_url"` Duration int32 `json:"duration"` Resolution *string `json:"resolution"` InstructorID *string `json:"instructor_id"` Thumbnail *string `json:"thumbnail"` Visibility *string `json:"visibility"` DisplayOrder int32 `json:"display_order"` IsPublished bool `json:"is_published"` PublishDate *string `json:"publish_date"` Status string `json:"status"` VimeoID *string `json:"vimeo_id,omitempty"` VimeoEmbedURL *string `json:"vimeo_embed_url,omitempty"` VimeoPlayerHTML *string `json:"vimeo_player_html,omitempty"` VimeoStatus *string `json:"vimeo_status,omitempty"` } type createVimeoVideoReq struct { SubCourseID int64 `json:"sub_course_id" validate:"required"` Title string `json:"title" validate:"required"` Description *string `json:"description"` SourceURL string `json:"source_url" validate:"required,url"` FileSize int64 `json:"file_size" validate:"required,gt=0"` Duration int32 `json:"duration"` Resolution *string `json:"resolution"` InstructorID *string `json:"instructor_id"` Thumbnail *string `json:"thumbnail"` Visibility *string `json:"visibility"` DisplayOrder *int32 `json:"display_order"` } type createVideoFromVimeoIDReq struct { SubCourseID int64 `json:"sub_course_id" validate:"required"` VimeoVideoID string `json:"vimeo_video_id" validate:"required"` Title string `json:"title" validate:"required"` Description *string `json:"description"` DisplayOrder *int32 `json:"display_order"` InstructorID *string `json:"instructor_id"` } // CreateSubCourseVideo godoc // @Summary Create a new sub-course video // @Description Creates a new video under a specific sub-course // @Tags sub-course-videos // @Accept json // @Produce json // @Param body body createSubCourseVideoReq true "Create video payload" // @Success 201 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/videos [post] func (h *Handler) CreateSubCourseVideo(c *fiber.Ctx) error { var req createSubCourseVideoReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } video, err := h.courseMgmtSvc.CreateSubCourseVideo(c.Context(), req.SubCourseID, req.Title, req.Description, req.VideoURL, req.Duration, req.Resolution, req.InstructorID, req.Thumbnail, req.Visibility, req.DisplayOrder, req.Status) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to create sub-course video", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"title": video.Title, "sub_course_id": video.SubCourseID}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoCreated, domain.ResourceVideo, &video.ID, "Created video: "+video.Title, meta, &ip, &ua) go func() { students, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: string(domain.RoleStudent)}) if err != nil { return } for _, s := range students { h.sendInAppNotification(s.ID, domain.NOTIFICATION_TYPE_VIDEO_ADDED, "New Video Available", "A new video \""+req.Title+"\" has been added.") } }() var publishDate *string if video.PublishDate != nil { pd := video.PublishDate.String() publishDate = &pd } return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Sub-course video created successfully", Data: subCourseVideoRes{ ID: video.ID, SubCourseID: video.SubCourseID, Title: video.Title, Description: video.Description, VideoURL: video.VideoURL, Duration: video.Duration, Resolution: video.Resolution, InstructorID: video.InstructorID, Thumbnail: video.Thumbnail, Visibility: video.Visibility, DisplayOrder: video.DisplayOrder, IsPublished: video.IsPublished, PublishDate: publishDate, Status: video.Status, }, }) } // GetSubCourseVideoByID godoc // @Summary Get sub-course video by ID // @Description Returns a single video by its ID // @Tags sub-course-videos // @Produce json // @Param id path int true "Video ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 404 {object} domain.ErrorResponse // @Router /api/v1/course-management/videos/{id} [get] func (h *Handler) GetSubCourseVideoByID(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid video ID", Error: err.Error(), }) } video, err := h.courseMgmtSvc.GetSubCourseVideoByID(c.Context(), id) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Video not found", Error: err.Error(), }) } role := c.Locals("role").(domain.Role) if role == domain.RoleStudent { userID := c.Locals("user_id").(int64) if video.Status != string(domain.ContentStatusPublished) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Video not found", }) } blockedBy, err := h.courseMgmtSvc.GetFirstIncompletePreviousVideo(c.Context(), userID, video.ID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to validate video access", Error: err.Error(), }) } if blockedBy != nil { return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ Message: "You must complete previous videos first", Error: fmt.Sprintf("Complete video '%s' (display_order=%d) before accessing this one", blockedBy.Title, blockedBy.DisplayOrder), }) } } var publishDate *string if video.PublishDate != nil { pd := video.PublishDate.String() publishDate = &pd } return c.JSON(domain.Response{ Message: "Video retrieved successfully", Data: subCourseVideoRes{ ID: video.ID, SubCourseID: video.SubCourseID, Title: video.Title, Description: video.Description, VideoURL: video.VideoURL, Duration: video.Duration, Resolution: video.Resolution, InstructorID: video.InstructorID, Thumbnail: video.Thumbnail, Visibility: video.Visibility, DisplayOrder: video.DisplayOrder, IsPublished: video.IsPublished, PublishDate: publishDate, Status: video.Status, }, }) } // CompleteSubCourseVideo godoc // @Summary Mark sub-course video as completed // @Description Marks the given video as completed for the authenticated learner // @Tags progression // @Produce json // @Param id path int true "Video ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 403 {object} domain.ErrorResponse // @Failure 404 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/progress/videos/{id}/complete [post] func (h *Handler) CompleteSubCourseVideo(c *fiber.Ctx) error { userID := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) if role != domain.RoleStudent { return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ Message: "Only learners can complete videos", }) } videoID, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid video ID", Error: err.Error(), }) } video, err := h.courseMgmtSvc.GetSubCourseVideoByID(c.Context(), videoID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Video not found", Error: err.Error(), }) } if video.Status != string(domain.ContentStatusPublished) { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Video not found", }) } blockedBy, err := h.courseMgmtSvc.GetFirstIncompletePreviousVideo(c.Context(), userID, videoID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to validate video completion", Error: err.Error(), }) } if blockedBy != nil { return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{ Message: "You must complete previous videos first", Error: fmt.Sprintf("Complete video '%s' (display_order=%d) before completing this one", blockedBy.Title, blockedBy.DisplayOrder), }) } if err := h.courseMgmtSvc.MarkVideoCompleted(c.Context(), userID, videoID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to complete video", Error: err.Error(), }) } if err := h.courseMgmtSvc.RecalculateSubCourseProgress(c.Context(), userID, video.SubCourseID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update sub-course progress", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Video completed", }) } type getVideosBySubCourseRes struct { Videos []subCourseVideoRes `json:"videos"` TotalCount int64 `json:"total_count"` } // GetVideosBySubCourse godoc // @Summary Get videos by sub-course // @Description Returns all videos under a specific sub-course // @Tags sub-course-videos // @Produce json // @Param subCourseId path int true "Sub-course ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/sub-courses/{subCourseId}/videos [get] func (h *Handler) GetVideosBySubCourse(c *fiber.Ctx) error { subCourseIDStr := c.Params("subCourseId") subCourseID, err := strconv.ParseInt(subCourseIDStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid sub-course ID", Error: err.Error(), }) } videos, totalCount, err := h.courseMgmtSvc.GetVideosBySubCourse(c.Context(), subCourseID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to retrieve videos", Error: err.Error(), }) } videoResponses := make([]subCourseVideoRes, 0, len(videos)) for _, v := range videos { videoResponses = append(videoResponses, mapVideoToResponse(v)) } return c.JSON(domain.Response{ Message: "Videos retrieved successfully", Data: getVideosBySubCourseRes{ Videos: videoResponses, TotalCount: totalCount, }, }) } // GetPublishedVideosBySubCourse godoc // @Summary Get published videos by sub-course // @Description Returns all published videos under a specific sub-course // @Tags sub-course-videos // @Produce json // @Param subCourseId path int true "Sub-course ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/sub-courses/{subCourseId}/videos/published [get] func (h *Handler) GetPublishedVideosBySubCourse(c *fiber.Ctx) error { subCourseIDStr := c.Params("subCourseId") subCourseID, err := strconv.ParseInt(subCourseIDStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid sub-course ID", Error: err.Error(), }) } videos, err := h.courseMgmtSvc.GetPublishedVideosBySubCourse(c.Context(), subCourseID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to retrieve published videos", Error: err.Error(), }) } videoResponses := make([]subCourseVideoRes, 0, len(videos)) for _, v := range videos { videoResponses = append(videoResponses, mapVideoToResponse(v)) } return c.JSON(domain.Response{ Message: "Published videos retrieved successfully", Data: videoResponses, }) } // PublishSubCourseVideo godoc // @Summary Publish sub-course video // @Description Publishes a video by its ID // @Tags sub-course-videos // @Produce json // @Param id path int true "Video ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/videos/{id}/publish [put] func (h *Handler) PublishSubCourseVideo(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid video ID", Error: err.Error(), }) } err = h.courseMgmtSvc.PublishSubCourseVideo(c.Context(), id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to publish video", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"id": id}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoPublished, domain.ResourceVideo, &id, fmt.Sprintf("Published video ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Video published successfully", }) } type updateSubCourseVideoReq struct { 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"` Thumbnail *string `json:"thumbnail"` DisplayOrder *int32 `json:"display_order"` Status *string `json:"status"` // DRAFT, PUBLISHED, INACTIVE, ARCHIVED } // UpdateSubCourseVideo godoc // @Summary Update sub-course video // @Description Updates a video's fields // @Tags sub-course-videos // @Accept json // @Produce json // @Param id path int true "Video ID" // @Param body body updateSubCourseVideoReq true "Update video payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/videos/{id} [put] func (h *Handler) UpdateSubCourseVideo(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid video ID", Error: err.Error(), }) } var req updateSubCourseVideoReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } err = h.courseMgmtSvc.UpdateSubCourseVideo(c.Context(), id, req.Title, req.Description, req.VideoURL, req.Duration, req.Resolution, req.Visibility, req.Thumbnail, req.DisplayOrder, req.Status) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update video", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"id": id, "title": req.Title}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoUpdated, domain.ResourceVideo, &id, fmt.Sprintf("Updated video ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Video updated successfully", }) } // DeleteSubCourseVideo godoc // @Summary Delete sub-course video // @Description Archives a video by its ID (soft delete) // @Tags sub-course-videos // @Produce json // @Param id path int true "Video ID" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/videos/{id} [delete] func (h *Handler) DeleteSubCourseVideo(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid video ID", Error: err.Error(), }) } err = h.courseMgmtSvc.ArchiveSubCourseVideo(c.Context(), id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to delete video", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"id": id}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoArchived, domain.ResourceVideo, &id, fmt.Sprintf("Archived video ID: %d", id), meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Video deleted successfully", }) } // NOTE: Practice and Practice Question handlers have been removed. // Use the unified questions system at /api/v1/questions and /api/v1/question-sets instead. // Create a question set with set_type="PRACTICE" and owner_type="SUB_COURSE" to replace practices. // Learning Tree Handler // GetFullLearningTree godoc // @Summary Get full learning tree // @Description Returns the complete learning tree structure with courses and sub-courses // @Tags learning-tree // @Produce json // @Success 200 {object} domain.Response // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/learning-tree [get] func (h *Handler) GetFullLearningTree(c *fiber.Ctx) error { courses, err := h.courseMgmtSvc.GetFullLearningTree(c.Context()) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to retrieve learning tree", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Learning tree retrieved successfully", Data: courses, }) } // GetCourseLearningPath godoc // @Summary Get course learning path // @Description Returns the complete learning path for a course including sub-courses (by level), // @Description video lessons, practices, prerequisites, and counts — structured for admin panel flexible path configuration // @Tags learning-tree // @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}/learning-path [get] func (h *Handler) GetCourseLearningPath(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(), }) } path, err := h.courseMgmtSvc.GetCourseLearningPath(c.Context(), courseID) if err != nil { return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{ Message: "Course not found or has no learning path", Error: err.Error(), }) } return c.JSON(domain.Response{ Message: "Learning path retrieved successfully", Data: path, }) } // Reorder Handlers — support drag-and-drop ordering from admin panel type reorderItem struct { ID int64 `json:"id" validate:"required"` Position int32 `json:"position"` } type reorderReq struct { Items []reorderItem `json:"items" validate:"required,min=1"` } func parseReorderItems(items []reorderItem) ([]int64, []int32) { ids := make([]int64, len(items)) positions := make([]int32, len(items)) for i, item := range items { ids[i] = item.ID positions[i] = item.Position } return ids, positions } // ReorderCourseCategories godoc // @Summary Reorder course categories // @Description Updates the display_order of course categories for drag-and-drop sorting // @Tags course-categories // @Accept json // @Produce json // @Param body body reorderReq true "Reorder payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/categories/reorder [put] func (h *Handler) ReorderCourseCategories(c *fiber.Ctx) error { var req reorderReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } if len(req.Items) == 0 { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Items array is required", Error: "items must not be empty", }) } ids, positions := parseReorderItems(req.Items) if err := h.courseMgmtSvc.ReorderCourseCategories(c.Context(), ids, positions); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to reorder course categories", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCategoryUpdated, domain.ResourceCategory, nil, "Reordered course categories", meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Course categories reordered successfully", }) } // ReorderCourses godoc // @Summary Reorder courses within a category // @Description Updates the display_order of courses for drag-and-drop sorting // @Tags courses // @Accept json // @Produce json // @Param body body reorderReq true "Reorder payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/courses/reorder [put] func (h *Handler) ReorderCourses(c *fiber.Ctx) error { var req reorderReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } if len(req.Items) == 0 { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Items array is required", Error: "items must not be empty", }) } ids, positions := parseReorderItems(req.Items) if err := h.courseMgmtSvc.ReorderCourses(c.Context(), ids, positions); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to reorder courses", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceCourse, nil, "Reordered courses", meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Courses reordered successfully", }) } // ReorderSubCourses godoc // @Summary Reorder sub-courses within a course // @Description Updates the display_order of sub-courses for drag-and-drop sorting // @Tags sub-courses // @Accept json // @Produce json // @Param body body reorderReq true "Reorder payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/sub-courses/reorder [put] func (h *Handler) ReorderSubCourses(c *fiber.Ctx) error { var req reorderReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } if len(req.Items) == 0 { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Items array is required", Error: "items must not be empty", }) } ids, positions := parseReorderItems(req.Items) if err := h.courseMgmtSvc.ReorderSubCourses(c.Context(), ids, positions); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to reorder sub-courses", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseUpdated, domain.ResourceSubCourse, nil, "Reordered sub-courses", meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Sub-courses reordered successfully", }) } // ReorderSubCourseVideos godoc // @Summary Reorder videos within a sub-course // @Description Updates the display_order of videos for drag-and-drop sorting // @Tags sub-course-videos // @Accept json // @Produce json // @Param body body reorderReq true "Reorder payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/videos/reorder [put] func (h *Handler) ReorderSubCourseVideos(c *fiber.Ctx) error { var req reorderReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } if len(req.Items) == 0 { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Items array is required", Error: "items must not be empty", }) } ids, positions := parseReorderItems(req.Items) if err := h.courseMgmtSvc.ReorderSubCourseVideos(c.Context(), ids, positions); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to reorder videos", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoUpdated, domain.ResourceVideo, nil, "Reordered sub-course videos", meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Videos reordered successfully", }) } // ReorderPractices godoc // @Summary Reorder practices (question sets) within a sub-course // @Description Updates the display_order of practices for drag-and-drop sorting // @Tags question-sets // @Accept json // @Produce json // @Param body body reorderReq true "Reorder payload" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/practices/reorder [put] func (h *Handler) ReorderPractices(c *fiber.Ctx) error { var req reorderReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } if len(req.Items) == 0 { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Items array is required", Error: "items must not be empty", }) } ids, positions := parseReorderItems(req.Items) if err := h.courseMgmtSvc.ReorderQuestionSets(c.Context(), ids, positions); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to reorder practices", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"ids": ids, "positions": positions}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionQuestionSetUpdated, domain.ResourceQuestionSet, nil, "Reordered practices", meta, &ip, &ua) return c.JSON(domain.Response{ Message: "Practices reordered successfully", }) } // UploadSubCourseVideo godoc // @Summary Upload a video file and create sub-course video // @Description Accepts a video file upload, uploads it to Vimeo via TUS, and creates a sub-course video record // @Tags sub-course-videos // @Accept multipart/form-data // @Produce json // @Param file formData file true "Video file" // @Param sub_course_id formData int true "Sub-course ID" // @Param title formData string true "Video title" // @Param description formData string false "Video description" // @Param duration formData int false "Duration in seconds" // @Param resolution formData string false "Video resolution" // @Param instructor_id formData string false "Instructor ID" // @Param thumbnail formData string false "Thumbnail URL" // @Param visibility formData string false "Visibility" // @Param display_order formData int false "Display order" // @Success 201 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/videos/upload [post] func (h *Handler) UploadSubCourseVideo(c *fiber.Ctx) error { subCourseIDStr := c.FormValue("sub_course_id") if subCourseIDStr == "" { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "sub_course_id is required", Error: "sub_course_id form field is empty", }) } subCourseID, err := strconv.ParseInt(subCourseIDStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid sub_course_id", Error: err.Error(), }) } title := c.FormValue("title") if title == "" { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "title is required", Error: "title form field is empty", }) } fileHeader, err := c.FormFile("file") if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Video file is required", Error: err.Error(), }) } const maxSize = 500 * 1024 * 1024 // 500 MB if fileHeader.Size > maxSize { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "File too large", Error: "Video file must be <= 500MB", }) } file, err := fileHeader.Open() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to read uploaded file", Error: err.Error(), }) } defer file.Close() var description *string if desc := c.FormValue("description"); desc != "" { description = &desc } var duration int32 if durStr := c.FormValue("duration"); durStr != "" { durVal, err := strconv.ParseInt(durStr, 10, 32) if err == nil { duration = int32(durVal) } } var resolution *string if res := c.FormValue("resolution"); res != "" { resolution = &res } var instructorID *string if iid := c.FormValue("instructor_id"); iid != "" { instructorID = &iid } var thumbnail *string if thumb := c.FormValue("thumbnail"); thumb != "" { thumbnail = &thumb } var visibility *string if vis := c.FormValue("visibility"); vis != "" { visibility = &vis } var displayOrder *int32 if doStr := c.FormValue("display_order"); doStr != "" { doVal, err := strconv.ParseInt(doStr, 10, 32) if err == nil { do := int32(doVal) displayOrder = &do } } video, err := h.courseMgmtSvc.CreateSubCourseVideoWithFileUpload( c.Context(), subCourseID, title, description, fileHeader.Filename, file, fileHeader.Size, duration, resolution, instructorID, thumbnail, visibility, displayOrder, ) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to upload video", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"title": video.Title, "sub_course_id": subCourseID}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoUploaded, domain.ResourceVideo, &video.ID, "Uploaded video: "+video.Title, meta, &ip, &ua) return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Video uploaded and created successfully", Data: mapVideoToResponse(video), Success: true, }) } // CreateSubCourseVideoWithVimeo godoc // @Summary Create a new sub-course video with Vimeo upload // @Description Creates a video by uploading to Vimeo from a source URL // @Tags sub-course-videos // @Accept json // @Produce json // @Param body body createVimeoVideoReq true "Create Vimeo video payload" // @Success 201 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/videos/vimeo [post] func (h *Handler) CreateSubCourseVideoWithVimeo(c *fiber.Ctx) error { var req createVimeoVideoReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } if valErrs, ok := h.validator.Validate(c, req); !ok { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Validation failed", Error: fmt.Sprintf("%v", valErrs), }) } video, err := h.courseMgmtSvc.CreateSubCourseVideoWithVimeo( c.Context(), req.SubCourseID, req.Title, req.Description, req.SourceURL, req.FileSize, req.Duration, req.Resolution, req.InstructorID, req.Thumbnail, req.Visibility, req.DisplayOrder, ) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to create video with Vimeo", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"title": video.Title, "sub_course_id": video.SubCourseID}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoCreated, domain.ResourceVideo, &video.ID, "Created video with Vimeo: "+video.Title, meta, &ip, &ua) return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Video created and uploaded to Vimeo successfully", Data: mapVideoToResponse(video), Success: true, }) } // CreateSubCourseVideoFromVimeoID godoc // @Summary Create a sub-course video from existing Vimeo video // @Description Creates a video record from an existing Vimeo video ID // @Tags sub-course-videos // @Accept json // @Produce json // @Param body body createVideoFromVimeoIDReq true "Create from Vimeo ID payload" // @Success 201 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/videos/vimeo/import [post] func (h *Handler) CreateSubCourseVideoFromVimeoID(c *fiber.Ctx) error { var req createVideoFromVimeoIDReq if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid request body", Error: err.Error(), }) } if valErrs, ok := h.validator.Validate(c, req); !ok { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Validation failed", Error: fmt.Sprintf("%v", valErrs), }) } video, err := h.courseMgmtSvc.CreateSubCourseVideoFromVimeoID( c.Context(), req.SubCourseID, req.VimeoVideoID, req.Title, req.Description, req.DisplayOrder, req.InstructorID, ) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to import video from Vimeo", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"title": video.Title, "sub_course_id": video.SubCourseID, "vimeo_video_id": req.VimeoVideoID}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionVideoCreated, domain.ResourceVideo, &video.ID, "Imported video from Vimeo: "+video.Title, meta, &ip, &ua) return c.Status(fiber.StatusCreated).JSON(domain.Response{ Message: "Video imported from Vimeo successfully", Data: mapVideoToResponse(video), Success: true, }) } // UploadCourseThumbnail godoc // @Summary Upload a thumbnail image for a course // @Description Uploads and optimizes a thumbnail image, then updates the course // @Tags courses // @Accept multipart/form-data // @Produce json // @Param id path int true "Course ID" // @Param file formData file true "Thumbnail image file (jpg, png, webp)" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/courses/{id}/thumbnail [post] func (h *Handler) UploadCourseThumbnail(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid course ID", Error: err.Error(), }) } publicPath, err := h.processAndSaveThumbnail(c, "thumbnails/courses") if err != nil { return err } if err := h.courseMgmtSvc.UpdateCourse(c.Context(), id, nil, nil, &publicPath, nil, nil); err != nil { _ = os.Remove(filepath.Join(".", publicPath)) return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update course thumbnail", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"course_id": id, "thumbnail": publicPath}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionCourseUpdated, domain.ResourceCourse, &id, fmt.Sprintf("Uploaded thumbnail for course ID: %d", id), meta, &ip, &ua) return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "Course thumbnail uploaded successfully", Data: map[string]string{"thumbnail_url": h.resolveFileURL(c, publicPath)}, Success: true, }) } // UploadSubCourseThumbnail godoc // @Summary Upload a thumbnail image for a sub-course // @Description Uploads and optimizes a thumbnail image, then updates the sub-course // @Tags sub-courses // @Accept multipart/form-data // @Produce json // @Param id path int true "Sub-course ID" // @Param file formData file true "Thumbnail image file (jpg, png, webp)" // @Success 200 {object} domain.Response // @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/course-management/sub-courses/{id}/thumbnail [post] func (h *Handler) UploadSubCourseThumbnail(c *fiber.Ctx) error { idStr := c.Params("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid sub-course ID", Error: err.Error(), }) } publicPath, err := h.processAndSaveThumbnail(c, "thumbnails/sub_courses") if err != nil { return err } if err := h.courseMgmtSvc.UpdateSubCourse(c.Context(), id, nil, nil, &publicPath, nil, nil, nil, nil); err != nil { _ = os.Remove(filepath.Join(".", publicPath)) return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to update sub-course thumbnail", Error: err.Error(), }) } actorID := c.Locals("user_id").(int64) actorRole := string(c.Locals("role").(domain.Role)) ip := c.IP() ua := c.Get("User-Agent") meta, _ := json.Marshal(map[string]interface{}{"sub_course_id": id, "thumbnail": publicPath}) go h.activityLogSvc.RecordAction(context.Background(), &actorID, &actorRole, domain.ActionSubCourseUpdated, domain.ResourceSubCourse, &id, fmt.Sprintf("Uploaded thumbnail for sub-course ID: %d", id), meta, &ip, &ua) return c.Status(fiber.StatusOK).JSON(domain.Response{ Message: "Sub-course thumbnail uploaded successfully", Data: map[string]string{"thumbnail_url": h.resolveFileURL(c, publicPath)}, Success: true, }) } // processAndSaveThumbnail handles file validation, CloudConvert optimization, and local storage. // It returns the public URL path or a fiber error response. func (h *Handler) processAndSaveThumbnail(c *fiber.Ctx, subDir string) (string, error) { fileHeader, err := c.FormFile("file") if err != nil { return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Image file is required", Error: err.Error(), }) } const maxSize = 10 * 1024 * 1024 // 10 MB if fileHeader.Size > maxSize { return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "File too large", Error: "Thumbnail image must be <= 10MB", }) } fh, err := fileHeader.Open() if err != nil { return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to read file", Error: err.Error(), }) } defer fh.Close() head := make([]byte, 512) n, _ := fh.Read(head) contentType := http.DetectContentType(head[:n]) if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" { return "", c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid file type", Error: "Only jpg, png and webp images are allowed", }) } rest, err := io.ReadAll(fh) if err != nil { return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to read file", Error: err.Error(), }) } data := append(head[:n], rest...) // Optimize via CloudConvert if available if h.cloudConvertSvc != nil { optimized, optErr := h.cloudConvertSvc.OptimizeImage( c.Context(), fileHeader.Filename, bytes.NewReader(data), int64(len(data)), 1200, 80, ) if optErr != nil { h.mongoLoggerSvc.Warn("CloudConvert thumbnail optimization failed, using original", zap.Error(optErr), ) } else { optimizedData, readErr := io.ReadAll(optimized.Data) optimized.Data.Close() if readErr == nil { data = optimizedData contentType = "image/webp" } } } ext := ".jpg" switch contentType { case "image/png": ext = ".png" case "image/webp": ext = ".webp" } // Upload to MinIO if available, otherwise save locally if h.minioSvc != nil { result, uploadErr := h.minioSvc.Upload(c.Context(), subDir, fileHeader.Filename, bytes.NewReader(data), int64(len(data)), contentType) if uploadErr != nil { return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to upload file to storage", Error: uploadErr.Error(), }) } return "minio://" + result.ObjectKey, nil } dir := filepath.Join(".", "static", subDir) if err := os.MkdirAll(dir, 0o755); err != nil { return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to create storage directory", Error: err.Error(), }) } filename := uuid.New().String() + ext fullpath := filepath.Join(dir, filename) if err := os.WriteFile(fullpath, data, 0o644); err != nil { return "", c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to save file", Error: err.Error(), }) } return "/static/" + subDir + "/" + filename, nil } // Helper function to map video to response func mapVideoToResponse(video domain.SubCourseVideo) subCourseVideoRes { var publishDate *string if video.PublishDate != nil { pd := video.PublishDate.Format("2006-01-02T15:04:05Z07:00") publishDate = &pd } return subCourseVideoRes{ ID: video.ID, SubCourseID: video.SubCourseID, Title: video.Title, Description: video.Description, VideoURL: video.VideoURL, Duration: video.Duration, Resolution: video.Resolution, InstructorID: video.InstructorID, Thumbnail: video.Thumbnail, Visibility: video.Visibility, DisplayOrder: video.DisplayOrder, IsPublished: video.IsPublished, PublishDate: publishDate, Status: video.Status, VimeoID: video.VimeoID, VimeoEmbedURL: video.VimeoEmbedURL, VimeoPlayerHTML: video.VimeoPlayerHTML, VimeoStatus: video.VimeoStatus, } }