package handlers import ( "errors" "net/http" "strconv" "veza-backend-api/internal/core/education" apperrors "veza-backend-api/internal/errors" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // EducationHandler handles education HTTP endpoints (v0.12.3 F276-F305) type EducationHandler struct { service *education.Service logger *zap.Logger } // NewEducationHandler creates a new EducationHandler func NewEducationHandler(service *education.Service, logger *zap.Logger) *EducationHandler { return &EducationHandler{ service: service, logger: logger, } } // --- Course endpoints --- // CreateCourse handles POST /courses func (h *EducationHandler) CreateCourse(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } var req education.CreateCourseRequest if err := c.ShouldBindJSON(&req); err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid request: title is required")) return } course, err := h.service.CreateCourse(c.Request.Context(), userID, req) if err != nil { if errors.Is(err, education.ErrSlugAlreadyTaken) { RespondWithAppError(c, apperrors.NewValidationError("Slug is already taken")) return } RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to create course", err)) return } RespondSuccess(c, http.StatusCreated, course) } // UpdateCourse handles PUT /courses/:id func (h *EducationHandler) UpdateCourse(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } var req education.UpdateCourseRequest if err := c.ShouldBindJSON(&req); err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid request body")) return } course, err := h.service.UpdateCourse(c.Request.Context(), userID, courseID, req) if err != nil { h.handleCourseError(c, err) return } RespondSuccess(c, http.StatusOK, course) } // PublishCourse handles POST /courses/:id/publish func (h *EducationHandler) PublishCourse(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } course, err := h.service.PublishCourse(c.Request.Context(), userID, courseID) if err != nil { h.handleCourseError(c, err) return } RespondSuccess(c, http.StatusOK, course) } // ArchiveCourse handles POST /courses/:id/archive func (h *EducationHandler) ArchiveCourse(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } course, err := h.service.ArchiveCourse(c.Request.Context(), userID, courseID) if err != nil { h.handleCourseError(c, err) return } RespondSuccess(c, http.StatusOK, course) } // GetCourse handles GET /courses/:id func (h *EducationHandler) GetCourse(c *gin.Context) { courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } course, err := h.service.GetCourse(c.Request.Context(), courseID) if err != nil { if errors.Is(err, education.ErrCourseNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Course")) return } RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get course", err)) return } RespondSuccess(c, http.StatusOK, course) } // GetCourseBySlug handles GET /courses/slug/:slug func (h *EducationHandler) GetCourseBySlug(c *gin.Context) { slug := c.Param("slug") if slug == "" { RespondWithAppError(c, apperrors.NewValidationError("Slug is required")) return } course, err := h.service.GetCourseBySlug(c.Request.Context(), slug) if err != nil { if errors.Is(err, education.ErrCourseNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Course")) return } RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get course", err)) return } RespondSuccess(c, http.StatusOK, course) } // ListPublishedCourses handles GET /courses func (h *EducationHandler) ListPublishedCourses(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) limit = clampLimit(limit) // SECURITY(MEDIUM-004) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) var category *string if cat := c.Query("category"); cat != "" { category = &cat } var level *education.CourseLevel if lvl := c.Query("level"); lvl != "" { l := education.CourseLevel(lvl) level = &l } var language *string if lang := c.Query("language"); lang != "" { language = &lang } courses, total, err := h.service.ListPublishedCourses(c.Request.Context(), category, level, language, limit, offset) if err != nil { RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list courses", err)) return } page := (offset / max(limit, 1)) + 1 totalPages := int((total + int64(limit) - 1) / int64(limit)) RespondSuccess(c, http.StatusOK, gin.H{ "data": courses, "pagination": gin.H{ "page": page, "limit": limit, "total": total, "total_pages": totalPages, }, }) } // ListCreatorCourses handles GET /creators/me/courses func (h *EducationHandler) ListCreatorCourses(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) limit = clampLimit(limit) // SECURITY(MEDIUM-004) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) courses, total, err := h.service.ListCreatorCourses(c.Request.Context(), userID, limit, offset) if err != nil { RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to list courses", err)) return } page := (offset / max(limit, 1)) + 1 totalPages := int((total + int64(limit) - 1) / int64(limit)) RespondSuccess(c, http.StatusOK, gin.H{ "data": courses, "pagination": gin.H{ "page": page, "limit": limit, "total": total, "total_pages": totalPages, }, }) } // DeleteCourse handles DELETE /courses/:id func (h *EducationHandler) DeleteCourse(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } if err := h.service.DeleteCourse(c.Request.Context(), userID, courseID); err != nil { h.handleCourseError(c, err) return } RespondSuccess(c, http.StatusOK, gin.H{"message": "Course deleted"}) } // --- Lesson endpoints --- // CreateLesson handles POST /courses/:id/lessons func (h *EducationHandler) CreateLesson(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } var req education.CreateLessonRequest if err := c.ShouldBindJSON(&req); err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid request: title is required")) return } lesson, err := h.service.CreateLesson(c.Request.Context(), userID, courseID, req) if err != nil { h.handleCourseError(c, err) return } RespondSuccess(c, http.StatusCreated, lesson) } // UpdateLesson handles PUT /courses/:id/lessons/:lesson_id func (h *EducationHandler) UpdateLesson(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } lessonID, err := uuid.Parse(c.Param("lesson_id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid lesson ID")) return } var req education.UpdateLessonRequest if err := c.ShouldBindJSON(&req); err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid request body")) return } lesson, err := h.service.UpdateLesson(c.Request.Context(), userID, courseID, lessonID, req) if err != nil { h.handleCourseError(c, err) return } RespondSuccess(c, http.StatusOK, lesson) } // DeleteLesson handles DELETE /courses/:id/lessons/:lesson_id func (h *EducationHandler) DeleteLesson(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } lessonID, err := uuid.Parse(c.Param("lesson_id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid lesson ID")) return } if err := h.service.DeleteLesson(c.Request.Context(), userID, courseID, lessonID); err != nil { h.handleCourseError(c, err) return } RespondSuccess(c, http.StatusOK, gin.H{"message": "Lesson deleted"}) } // GetCourseLessons handles GET /courses/:id/lessons func (h *EducationHandler) GetCourseLessons(c *gin.Context) { courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } lessons, err := h.service.GetCourseLessons(c.Request.Context(), courseID) if err != nil { RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get lessons", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"lessons": lessons}) } // ReorderLessons handles POST /courses/:id/lessons/reorder func (h *EducationHandler) ReorderLessons(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } var req education.ReorderLessonsRequest if err := c.ShouldBindJSON(&req); err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid request: lesson_ids is required")) return } if err := h.service.ReorderLessons(c.Request.Context(), userID, courseID, req); err != nil { h.handleCourseError(c, err) return } RespondSuccess(c, http.StatusOK, gin.H{"message": "Lessons reordered"}) } // --- Enrollment endpoints --- // Enroll handles POST /courses/:id/enroll func (h *EducationHandler) Enroll(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } var req education.EnrollRequest if err := c.ShouldBindJSON(&req); err != nil { // Allow empty body for free courses req = education.EnrollRequest{} } enrollment, err := h.service.Enroll(c.Request.Context(), userID, courseID, req) if err != nil { switch { case errors.Is(err, education.ErrCourseNotFound): RespondWithAppError(c, apperrors.NewNotFoundError("Course")) case errors.Is(err, education.ErrCourseNotPublished): RespondWithAppError(c, apperrors.NewValidationError("Course is not published")) case errors.Is(err, education.ErrAlreadyEnrolled): RespondWithAppError(c, apperrors.NewValidationError("Already enrolled in this course")) case errors.Is(err, education.ErrCannotEnrollOwnCourse): RespondWithAppError(c, apperrors.NewValidationError("Cannot enroll in your own course")) default: RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to enroll", err)) } return } RespondSuccess(c, http.StatusCreated, enrollment) } // GetMyEnrollments handles GET /enrollments func (h *EducationHandler) GetMyEnrollments(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) limit = clampLimit(limit) // SECURITY(MEDIUM-004) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) enrollments, total, err := h.service.GetUserEnrollments(c.Request.Context(), userID, limit, offset) if err != nil { RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get enrollments", err)) return } page := (offset / max(limit, 1)) + 1 totalPages := int((total + int64(limit) - 1) / int64(limit)) RespondSuccess(c, http.StatusOK, gin.H{ "data": enrollments, "pagination": gin.H{ "page": page, "limit": limit, "total": total, "total_pages": totalPages, }, }) } // --- Progress endpoints --- // UpdateProgress handles POST /lessons/:lesson_id/progress func (h *EducationHandler) UpdateProgress(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } lessonID, err := uuid.Parse(c.Param("lesson_id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid lesson ID")) return } var req education.UpdateProgressRequest if err := c.ShouldBindJSON(&req); err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid request body")) return } progress, err := h.service.UpdateProgress(c.Request.Context(), userID, lessonID, req) if err != nil { switch { case errors.Is(err, education.ErrLessonNotFound): RespondWithAppError(c, apperrors.NewNotFoundError("Lesson")) case errors.Is(err, education.ErrNotEnrolled): RespondWithAppError(c, apperrors.NewForbiddenError("Not enrolled in this course")) default: RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to update progress", err)) } return } RespondSuccess(c, http.StatusOK, progress) } // GetCourseProgress handles GET /courses/:id/progress func (h *EducationHandler) GetCourseProgress(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } progress, err := h.service.GetCourseProgress(c.Request.Context(), userID, courseID) if err != nil { if errors.Is(err, education.ErrNotEnrolled) { RespondWithAppError(c, apperrors.NewForbiddenError("Not enrolled in this course")) return } RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get progress", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"progress": progress}) } // --- Certificate endpoints --- // IssueCertificate handles POST /courses/:id/certificate func (h *EducationHandler) IssueCertificate(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } cert, err := h.service.IssueCertificate(c.Request.Context(), userID, courseID) if err != nil { switch { case errors.Is(err, education.ErrNotEnrolled): RespondWithAppError(c, apperrors.NewForbiddenError("Not enrolled in this course")) case errors.Is(err, education.ErrCourseNotCompleted): RespondWithAppError(c, apperrors.NewValidationError("Course is not fully completed")) case errors.Is(err, education.ErrCertificateExists): RespondWithAppError(c, apperrors.NewValidationError("Certificate already issued")) default: RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to issue certificate", err)) } return } RespondSuccess(c, http.StatusCreated, cert) } // VerifyCertificate handles GET /certificates/:code func (h *EducationHandler) VerifyCertificate(c *gin.Context) { code := c.Param("code") if code == "" { RespondWithAppError(c, apperrors.NewValidationError("Certificate code is required")) return } cert, err := h.service.GetCertificate(c.Request.Context(), code) if err != nil { if errors.Is(err, education.ErrCertificateNotFound) { RespondWithAppError(c, apperrors.NewNotFoundError("Certificate")) return } RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to verify certificate", err)) return } RespondSuccess(c, http.StatusOK, cert) } // GetMyCertificates handles GET /certificates func (h *EducationHandler) GetMyCertificates(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } certs, err := h.service.GetUserCertificates(c.Request.Context(), userID) if err != nil { RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get certificates", err)) return } RespondSuccess(c, http.StatusOK, gin.H{"certificates": certs}) } // --- Review endpoints --- // CreateReview handles POST /courses/:id/reviews func (h *EducationHandler) CreateReview(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } var req education.CreateReviewRequest if err := c.ShouldBindJSON(&req); err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid request: rating and content are required")) return } review, err := h.service.CreateReview(c.Request.Context(), userID, courseID, req) if err != nil { switch { case errors.Is(err, education.ErrNotEnrolled): RespondWithAppError(c, apperrors.NewForbiddenError("Must be enrolled to review")) case errors.Is(err, education.ErrReviewAlreadyExists): RespondWithAppError(c, apperrors.NewValidationError("Already reviewed this course")) case errors.Is(err, education.ErrInvalidRating): RespondWithAppError(c, apperrors.NewValidationError("Rating must be between 1 and 5")) default: RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to create review", err)) } return } RespondSuccess(c, http.StatusCreated, review) } // GetCourseReviews handles GET /courses/:id/reviews func (h *EducationHandler) GetCourseReviews(c *gin.Context) { courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) limit = clampLimit(limit) // SECURITY(MEDIUM-004) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) reviews, total, err := h.service.GetCourseReviews(c.Request.Context(), courseID, limit, offset) if err != nil { RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get reviews", err)) return } page := (offset / max(limit, 1)) + 1 totalPages := int((total + int64(limit) - 1) / int64(limit)) RespondSuccess(c, http.StatusOK, gin.H{ "data": reviews, "pagination": gin.H{ "page": page, "limit": limit, "total": total, "total_pages": totalPages, }, }) } // UploadLessonVideo handles POST /courses/:id/lessons/:lesson_id/video func (h *EducationHandler) UploadLessonVideo(c *gin.Context) { userID, ok := GetUserIDUUID(c) if !ok { return } courseID, err := uuid.Parse(c.Param("id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid course ID")) return } lessonID, err := uuid.Parse(c.Param("lesson_id")) if err != nil { RespondWithAppError(c, apperrors.NewValidationError("Invalid lesson ID")) return } fileHeader, err := c.FormFile("video") if err != nil { RespondWithAppError(c, apperrors.NewValidationError("No video file provided")) return } // Validate file size (max 500MB) const maxVideoSize = 500 * 1024 * 1024 if fileHeader.Size > maxVideoSize { RespondWithAppError(c, apperrors.NewValidationError("Video file too large (max 500MB)")) return } // Validate content type contentType := fileHeader.Header.Get("Content-Type") allowedTypes := map[string]bool{ "video/mp4": true, "video/webm": true, "video/ogg": true, "video/avi": true, } if !allowedTypes[contentType] { RespondWithAppError(c, apperrors.NewValidationError("Unsupported video format. Accepted: mp4, webm, ogg, avi")) return } // Save file to upload directory uploadDir := "/tmp/veza-uploads/videos" filename := lessonID.String() + "_" + fileHeader.Filename savePath := uploadDir + "/" + filename if err := c.SaveUploadedFile(fileHeader, savePath); err != nil { h.logger.Error("Failed to save video file", zap.Error(err), zap.String("lesson_id", lessonID.String()), ) RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to save video", err)) return } // Update lesson with video path (triggers transcoding pipeline) lesson, err := h.service.SetLessonVideoPath(c.Request.Context(), userID, courseID, lessonID, savePath, 0) if err != nil { h.handleCourseError(c, err) return } h.logger.Info("Lesson video uploaded", zap.String("lesson_id", lessonID.String()), zap.String("course_id", courseID.String()), zap.Int64("file_size", fileHeader.Size), ) RespondSuccess(c, http.StatusOK, lesson) } // handleCourseError maps common course errors to HTTP responses func (h *EducationHandler) handleCourseError(c *gin.Context, err error) { switch { case errors.Is(err, education.ErrCourseNotFound): RespondWithAppError(c, apperrors.NewNotFoundError("Course")) case errors.Is(err, education.ErrLessonNotFound): RespondWithAppError(c, apperrors.NewNotFoundError("Lesson")) case errors.Is(err, education.ErrNotCourseOwner): RespondWithAppError(c, apperrors.NewForbiddenError("You are not the owner of this course")) default: RespondWithAppError(c, apperrors.NewInternalErrorWrap("Operation failed", err)) } }