MEDIUM-002: Remove manual X-Forwarded-For parsing in metrics_protection.go, use c.ClientIP() only (respects SetTrustedProxies) MEDIUM-003: Pin ClamAV Docker image to 1.4 across all compose files MEDIUM-004: Add clampLimit(100) to 15+ handlers that parsed limit directly MEDIUM-006: Remove unsafe-eval from CSP script-src on Swagger routes MEDIUM-007: Pin all GitHub Actions to SHA in 11 workflow files MEDIUM-008: Replace rabbitmq:3-management-alpine with rabbitmq:3-alpine in prod MEDIUM-009: Add trial-already-used check in subscription service MEDIUM-010: Add 60s periodic token re-validation to WebSocket connections MEDIUM-011: Mask email in auth handler logs with maskEmail() helper MEDIUM-012: Add k-anonymity threshold (k=5) to playback analytics stats LOW-001: Align frontend password policy to 12 chars (matching backend) LOW-003: Replace deprecated dotenv with dotenvy crate in Rust stream server LOW-004: Enable xpack.security in Elasticsearch dev/local compose files LOW-005: Accept context.Context in CleanupExpiredSessions instead of Background() LOW-002: Noted — Hyperswitch version update deferred (requires payment integration tests) 29/30 findings remediated. 1 noted (LOW-002). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
762 lines
21 KiB
Go
762 lines
21 KiB
Go
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))
|
|
}
|
|
}
|