Yimaru-BackEnd/internal/web_server/handlers/course_management.go

2329 lines
77 KiB
Go

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(),
})
}
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": 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": 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"
}
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,
}
}