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>
764 lines
21 KiB
Go
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))
|
|
}
|
|
}
|