veza/veza-backend-api/internal/handlers/education_handler.go
senke 7974517c03 feat(backend,web): single source of truth for upload-size limits
Second item of the v1.0.6 backlog. The "front 500MB vs back 100MB" mismatch
flagged in the v1.0.5 audit turned out to be a misread — every live pair
was already aligned (tracks 100/100, cloud 500/500, video 500/500). The
real bug is architectural: the same byte values were duplicated in five
places (`track/service.go`, `handlers/upload.go:GetUploadLimits`,
`handlers/education_handler.go`, `upload-modal/constants.ts`, and
`CloudUploadModal.tsx`), drifting silently as soon as anyone tuned one.

Backend — one canonical spec at `internal/config/upload_limits.go`:
  * `AudioLimit`, `ImageLimit`, `VideoLimit` expose `Bytes()`, `MB()`,
    `HumanReadable()`, `AllowedMIMEs` — read lazily from env
    (`MAX_UPLOAD_AUDIO_MB`, `MAX_UPLOAD_IMAGE_MB`, `MAX_UPLOAD_VIDEO_MB`)
    with defaults 100/10/500.
  * Invalid / negative / zero env values fall back to the default;
    unreadable config can't turn the limit off silently.
  * `track.Service.maxFileSize`, `track_upload_handler.go` error string,
    `education_handler.go` video gate, and `upload.go:GetUploadLimits`
    all read from this single source. Changing `MAX_UPLOAD_AUDIO_MB`
    retunes every path at once.

Frontend — new `useUploadLimits()` hook:
  * Fetches GET `/api/v1/upload/limits` via react-query (5 min stale,
    30 min gc), one retry, then silently falls back to baked-in
    defaults that match the backend compile-time defaults so the
    dropzone stays responsive even without the network round-trip.
  * `useUploadModal.ts` replaces its hardcoded `MAX_FILE_SIZE`
    constant with `useUploadLimits().audio.maxBytes`, and surfaces
    `audioMaxHuman` up to `UploadModal` → `UploadModalDropzone` so
    the "max 100 MB" label and the "too large" error toast both
    display the live value.
  * `MAX_FILE_SIZE` constant kept as pure fallback for pre-network
    render (documented as such).

Tests
  * 4 Go tests on `config.UploadLimit` (defaults, env override, invalid
    env → fallback, non-empty MIME lists).
  * 4 Vitest tests on `useUploadLimits` (sync fallback on first render,
    typed mapping from server payload, partial-payload falls back
    per-category, network failure keeps fallback).
  * Existing `trackUpload.integration.test.tsx` (11 cases) still green.

Out of scope (tracked for later):
  * `CloudUploadModal.tsx` still has its own 500MB hardcoded — cloud
    uploads accept audio+zip+midi with a different category semantic
    than the three in `/upload/limits`. Unifying those deserves its
    own design pass, not a drive-by.
  * No runtime refactor of admin-provided custom category limits —
    the current tri-category split covers every upload we ship today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 19:37:37 +02:00

764 lines
21 KiB
Go

package handlers
import (
"errors"
"net/http"
"strconv"
"veza-backend-api/internal/config"
"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
}
// v1.0.6: uses the canonical video limit from config (honors
// MAX_UPLOAD_VIDEO_MB env override, matches GET /upload/limits).
if fileHeader.Size > config.VideoLimit.Bytes() {
RespondWithAppError(c, apperrors.NewValidationError(
"Video file too large (max "+config.VideoLimit.HumanReadable()+")"))
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))
}
}