2335 lines
78 KiB
Go
2335 lines
78 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(),
|
|
})
|
|
}
|
|
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": 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,
|
|
}
|
|
}
|