feat(v0.12.3): F276-F305 education backend service, handler, and routes
- Course CRUD with slug generation, publish/archive lifecycle - Lesson management with ordering and transcoding status - Enrollment system with duplicate prevention - Progress tracking with auto-completion at 90% - Certificate issuance requiring full course completion - Course reviews with rating aggregation - Unit tests for service and handler layers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
329f53ada3
commit
506195f4e0
7 changed files with 2405 additions and 0 deletions
|
|
@ -352,6 +352,9 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
|
|||
|
||||
// v0.12.2: Distribution to External Platforms (F501-F510)
|
||||
r.setupDistributionRoutes(v1)
|
||||
|
||||
// v0.12.3: Formation & Éducation (F276-F305)
|
||||
r.setupEducationRoutes(v1)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
69
veza-backend-api/internal/api/routes_education.go
Normal file
69
veza-backend-api/internal/api/routes_education.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"veza-backend-api/internal/core/education"
|
||||
"veza-backend-api/internal/handlers"
|
||||
)
|
||||
|
||||
// setupEducationRoutes configures routes for Formation & Éducation (v0.12.3 F276-F305)
|
||||
func (r *APIRouter) setupEducationRoutes(router *gin.RouterGroup) {
|
||||
svc := education.NewService(r.db.GormDB, r.logger)
|
||||
handler := handlers.NewEducationHandler(svc, r.logger)
|
||||
|
||||
// Public course catalog
|
||||
coursePublic := router.Group("/courses")
|
||||
coursePublic.GET("", handler.ListPublishedCourses)
|
||||
coursePublic.GET("/:id", handler.GetCourse)
|
||||
coursePublic.GET("/:id/lessons", handler.GetCourseLessons)
|
||||
coursePublic.GET("/:id/reviews", handler.GetCourseReviews)
|
||||
coursePublic.GET("/slug/:slug", handler.GetCourseBySlug)
|
||||
|
||||
// Public certificate verification
|
||||
router.GET("/certificates/:code", handler.VerifyCertificate)
|
||||
|
||||
if r.config.AuthMiddleware == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Course management (requires auth)
|
||||
courseAuth := router.Group("/courses")
|
||||
courseAuth.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
r.applyCSRFProtection(courseAuth)
|
||||
|
||||
courseAuth.POST("", handler.CreateCourse)
|
||||
courseAuth.PUT("/:id", handler.UpdateCourse)
|
||||
courseAuth.DELETE("/:id", handler.DeleteCourse)
|
||||
courseAuth.POST("/:id/publish", handler.PublishCourse)
|
||||
courseAuth.POST("/:id/archive", handler.ArchiveCourse)
|
||||
courseAuth.POST("/:id/lessons", handler.CreateLesson)
|
||||
courseAuth.PUT("/:id/lessons/:lesson_id", handler.UpdateLesson)
|
||||
courseAuth.DELETE("/:id/lessons/:lesson_id", handler.DeleteLesson)
|
||||
courseAuth.POST("/:id/lessons/reorder", handler.ReorderLessons)
|
||||
courseAuth.POST("/:id/enroll", handler.Enroll)
|
||||
courseAuth.GET("/:id/progress", handler.GetCourseProgress)
|
||||
courseAuth.POST("/:id/certificate", handler.IssueCertificate)
|
||||
courseAuth.POST("/:id/reviews", handler.CreateReview)
|
||||
|
||||
// Enrollments (requires auth)
|
||||
enrollAuth := router.Group("/enrollments")
|
||||
enrollAuth.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
enrollAuth.GET("", handler.GetMyEnrollments)
|
||||
|
||||
// Progress (requires auth)
|
||||
lessonAuth := router.Group("/lessons")
|
||||
lessonAuth.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
r.applyCSRFProtection(lessonAuth)
|
||||
lessonAuth.POST("/:lesson_id/progress", handler.UpdateProgress)
|
||||
|
||||
// Certificates (requires auth for user's own)
|
||||
certAuth := router.Group("/certificates")
|
||||
certAuth.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
certAuth.GET("", handler.GetMyCertificates)
|
||||
|
||||
// Creator courses list
|
||||
creatorAuth := router.Group("/creators/me")
|
||||
creatorAuth.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
creatorAuth.GET("/courses", handler.ListCreatorCourses)
|
||||
}
|
||||
241
veza-backend-api/internal/core/education/models.go
Normal file
241
veza-backend-api/internal/core/education/models.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
package education
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CourseStatus represents the publication status of a course
|
||||
type CourseStatus string
|
||||
|
||||
const (
|
||||
CourseStatusDraft CourseStatus = "draft"
|
||||
CourseStatusPublished CourseStatus = "published"
|
||||
CourseStatusArchived CourseStatus = "archived"
|
||||
)
|
||||
|
||||
// CourseLevel represents the difficulty level
|
||||
type CourseLevel string
|
||||
|
||||
const (
|
||||
CourseLevelBeginner CourseLevel = "beginner"
|
||||
CourseLevelIntermediate CourseLevel = "intermediate"
|
||||
CourseLevelAdvanced CourseLevel = "advanced"
|
||||
)
|
||||
|
||||
// PricingModel represents the pricing strategy
|
||||
type PricingModel string
|
||||
|
||||
const (
|
||||
PricingFixed PricingModel = "fixed"
|
||||
PricingPayWhatYou PricingModel = "pay_what_you_want"
|
||||
PricingFree PricingModel = "free"
|
||||
)
|
||||
|
||||
// EnrollmentStatus represents the enrollment state
|
||||
type EnrollmentStatus string
|
||||
|
||||
const (
|
||||
EnrollmentActive EnrollmentStatus = "active"
|
||||
EnrollmentExpired EnrollmentStatus = "expired"
|
||||
EnrollmentRefunded EnrollmentStatus = "refunded"
|
||||
)
|
||||
|
||||
// TranscodingStatus represents video processing state
|
||||
type TranscodingStatus string
|
||||
|
||||
const (
|
||||
TranscodingPending TranscodingStatus = "pending"
|
||||
TranscodingProcessing TranscodingStatus = "processing"
|
||||
TranscodingComplete TranscodingStatus = "complete"
|
||||
TranscodingFailed TranscodingStatus = "failed"
|
||||
)
|
||||
|
||||
// CertificateStatus represents certificate validity
|
||||
type CertificateStatus string
|
||||
|
||||
const (
|
||||
CertificateActive CertificateStatus = "active"
|
||||
CertificateRevoked CertificateStatus = "revoked"
|
||||
)
|
||||
|
||||
// ReviewStatus represents review moderation status
|
||||
type ReviewStatus string
|
||||
|
||||
const (
|
||||
ReviewApproved ReviewStatus = "approved"
|
||||
ReviewPending ReviewStatus = "pending"
|
||||
ReviewRejected ReviewStatus = "rejected"
|
||||
)
|
||||
|
||||
// Course represents an educational course created by a user
|
||||
type Course struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
CreatorID uuid.UUID `gorm:"type:uuid;not null" json:"creator_id"`
|
||||
Title string `gorm:"size:255;not null" json:"title"`
|
||||
Slug string `gorm:"size:255;not null;uniqueIndex" json:"slug"`
|
||||
Description string `gorm:"type:text;not null;default:''" json:"description"`
|
||||
Category string `gorm:"size:100" json:"category,omitempty"`
|
||||
Tags pq.StringArray `gorm:"type:varchar(50)[];default:'{}'" json:"tags"`
|
||||
CoverImageURL string `gorm:"type:text" json:"cover_image_url,omitempty"`
|
||||
PriceCents int `gorm:"not null;default:0" json:"price_cents"`
|
||||
Currency string `gorm:"size:3;not null;default:'USD'" json:"currency"`
|
||||
PricingModel PricingModel `gorm:"size:50;not null;default:'fixed'" json:"pricing_model"`
|
||||
MinimumPriceCents int `gorm:"default:0" json:"minimum_price_cents"`
|
||||
Status CourseStatus `gorm:"size:50;not null;default:'draft'" json:"status"`
|
||||
Level CourseLevel `gorm:"size:50;default:'beginner'" json:"level"`
|
||||
Language string `gorm:"size:5;default:'en'" json:"language"`
|
||||
TotalDurationSeconds int `gorm:"not null;default:0" json:"total_duration_seconds"`
|
||||
LessonCount int `gorm:"not null;default:0" json:"lesson_count"`
|
||||
EnrollmentCount int `gorm:"not null;default:0" json:"enrollment_count"`
|
||||
ReviewCount int `gorm:"not null;default:0" json:"review_count"`
|
||||
AverageRating float64 `gorm:"type:numeric(3,2);default:0" json:"average_rating"`
|
||||
PublishedAt *time.Time `json:"published_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
func (Course) TableName() string {
|
||||
return "courses"
|
||||
}
|
||||
|
||||
func (c *Course) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.ID == uuid.Nil {
|
||||
c.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lesson represents a single lesson within a course
|
||||
type Lesson struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
CourseID uuid.UUID `gorm:"type:uuid;not null" json:"course_id"`
|
||||
OrderIndex int `gorm:"not null" json:"order_index"`
|
||||
Title string `gorm:"size:255;not null" json:"title"`
|
||||
Description string `gorm:"type:text" json:"description,omitempty"`
|
||||
VideoFilePath string `gorm:"size:512" json:"video_file_path,omitempty"`
|
||||
DurationSeconds int `gorm:"not null;default:0" json:"duration_seconds"`
|
||||
IsPreviewFree bool `gorm:"not null;default:false" json:"is_preview_free"`
|
||||
TranscodingStatus TranscodingStatus `gorm:"size:50;not null;default:'pending'" json:"transcoding_status"`
|
||||
HLSMasterPlaylistURL string `gorm:"type:text" json:"hls_master_playlist_url,omitempty"`
|
||||
ThumbnailURL string `gorm:"type:text" json:"thumbnail_url,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Lesson) TableName() string {
|
||||
return "lessons"
|
||||
}
|
||||
|
||||
func (l *Lesson) BeforeCreate(tx *gorm.DB) error {
|
||||
if l.ID == uuid.Nil {
|
||||
l.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CourseEnrollment represents a user's enrollment (purchase) in a course
|
||||
type CourseEnrollment struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
|
||||
CourseID uuid.UUID `gorm:"type:uuid;not null" json:"course_id"`
|
||||
PurchasedPriceCents int `gorm:"not null;default:0" json:"purchased_price_cents"`
|
||||
Currency string `gorm:"size:3;not null;default:'USD'" json:"currency"`
|
||||
Status EnrollmentStatus `gorm:"size:50;not null;default:'active'" json:"status"`
|
||||
AccessExpiresAt *time.Time `json:"access_expires_at,omitempty"`
|
||||
PurchasedAt time.Time `gorm:"not null" json:"purchased_at"`
|
||||
RefundedAt *time.Time `json:"refunded_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (CourseEnrollment) TableName() string {
|
||||
return "course_enrollments"
|
||||
}
|
||||
|
||||
func (e *CourseEnrollment) BeforeCreate(tx *gorm.DB) error {
|
||||
if e.ID == uuid.Nil {
|
||||
e.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LessonProgress tracks a user's progress on a specific lesson
|
||||
type LessonProgress struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
|
||||
LessonID uuid.UUID `gorm:"type:uuid;not null" json:"lesson_id"`
|
||||
EnrollmentID uuid.UUID `gorm:"type:uuid;not null" json:"enrollment_id"`
|
||||
WatchedPercentage int `gorm:"default:0" json:"watched_percentage"`
|
||||
WatchedDurationSeconds int `gorm:"default:0" json:"watched_duration_seconds"`
|
||||
PlaybackPositionSeconds int `gorm:"default:0" json:"playback_position_seconds"`
|
||||
IsCompleted bool `gorm:"not null;default:false" json:"is_completed"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (LessonProgress) TableName() string {
|
||||
return "lesson_progress"
|
||||
}
|
||||
|
||||
func (p *LessonProgress) BeforeCreate(tx *gorm.DB) error {
|
||||
if p.ID == uuid.Nil {
|
||||
p.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Certificate represents a course completion certificate
|
||||
type Certificate struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
|
||||
CourseID uuid.UUID `gorm:"type:uuid;not null" json:"course_id"`
|
||||
EnrollmentID uuid.UUID `gorm:"type:uuid;not null" json:"enrollment_id"`
|
||||
CertificateCode string `gorm:"size:255;not null;uniqueIndex" json:"certificate_code"`
|
||||
IssueDate time.Time `gorm:"not null" json:"issue_date"`
|
||||
Status CertificateStatus `gorm:"size:50;not null;default:'active'" json:"status"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (Certificate) TableName() string {
|
||||
return "certificates"
|
||||
}
|
||||
|
||||
func (cert *Certificate) BeforeCreate(tx *gorm.DB) error {
|
||||
if cert.ID == uuid.Nil {
|
||||
cert.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CourseReview represents a user's review of a course
|
||||
type CourseReview struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
CourseID uuid.UUID `gorm:"type:uuid;not null" json:"course_id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
|
||||
EnrollmentID uuid.UUID `gorm:"type:uuid;not null" json:"enrollment_id"`
|
||||
Rating int `gorm:"not null" json:"rating"`
|
||||
Title string `gorm:"size:255" json:"title,omitempty"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Status ReviewStatus `gorm:"size:50;not null;default:'approved'" json:"status"`
|
||||
HelpfulCount int `gorm:"not null;default:0" json:"helpful_count"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
func (CourseReview) TableName() string {
|
||||
return "course_reviews"
|
||||
}
|
||||
|
||||
func (r *CourseReview) BeforeCreate(tx *gorm.DB) error {
|
||||
if r.ID == uuid.Nil {
|
||||
r.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
907
veza-backend-api/internal/core/education/service.go
Normal file
907
veza-backend-api/internal/core/education/service.go
Normal file
|
|
@ -0,0 +1,907 @@
|
|||
package education
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Service errors
|
||||
var (
|
||||
ErrCourseNotFound = errors.New("course not found")
|
||||
ErrLessonNotFound = errors.New("lesson not found")
|
||||
ErrEnrollmentNotFound = errors.New("enrollment not found")
|
||||
ErrAlreadyEnrolled = errors.New("already enrolled in this course")
|
||||
ErrNotEnrolled = errors.New("not enrolled in this course")
|
||||
ErrCourseNotPublished = errors.New("course is not published")
|
||||
ErrNotCourseOwner = errors.New("you are not the owner of this course")
|
||||
ErrReviewAlreadyExists = errors.New("you have already reviewed this course")
|
||||
ErrInvalidRating = errors.New("rating must be between 1 and 5")
|
||||
ErrCertificateNotFound = errors.New("certificate not found")
|
||||
ErrCourseNotCompleted = errors.New("course is not fully completed")
|
||||
ErrCertificateExists = errors.New("certificate already issued for this course")
|
||||
ErrCannotEnrollOwnCourse = errors.New("cannot enroll in your own course")
|
||||
ErrSlugAlreadyTaken = errors.New("slug is already taken")
|
||||
)
|
||||
|
||||
// ServiceOption is a functional option for configuring the Service
|
||||
type ServiceOption func(*Service)
|
||||
|
||||
// Service handles education business logic
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewService creates a new education service
|
||||
func NewService(db *gorm.DB, logger *zap.Logger, opts ...ServiceOption) *Service {
|
||||
s := &Service{
|
||||
db: db,
|
||||
logger: logger,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// --- Course CRUD ---
|
||||
|
||||
// CreateCourseRequest holds the fields for creating a course
|
||||
type CreateCourseRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
CoverImageURL string `json:"cover_image_url"`
|
||||
PriceCents int `json:"price_cents"`
|
||||
Currency string `json:"currency"`
|
||||
PricingModel PricingModel `json:"pricing_model"`
|
||||
MinimumPriceCents int `json:"minimum_price_cents"`
|
||||
Level CourseLevel `json:"level"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// CreateCourse creates a new course in draft status
|
||||
func (s *Service) CreateCourse(ctx context.Context, creatorID uuid.UUID, req CreateCourseRequest) (*Course, error) {
|
||||
slug := generateSlug(req.Title)
|
||||
|
||||
// Ensure slug uniqueness
|
||||
var count int64
|
||||
s.db.WithContext(ctx).Model(&Course{}).Where("slug = ?", slug).Count(&count)
|
||||
if count > 0 {
|
||||
slug = fmt.Sprintf("%s-%s", slug, uuid.New().String()[:8])
|
||||
}
|
||||
|
||||
currency := req.Currency
|
||||
if currency == "" {
|
||||
currency = "USD"
|
||||
}
|
||||
pricingModel := req.PricingModel
|
||||
if pricingModel == "" {
|
||||
pricingModel = PricingFixed
|
||||
}
|
||||
level := req.Level
|
||||
if level == "" {
|
||||
level = CourseLevelBeginner
|
||||
}
|
||||
language := req.Language
|
||||
if language == "" {
|
||||
language = "en"
|
||||
}
|
||||
|
||||
course := &Course{
|
||||
CreatorID: creatorID,
|
||||
Title: req.Title,
|
||||
Slug: slug,
|
||||
Description: req.Description,
|
||||
Category: req.Category,
|
||||
Tags: req.Tags,
|
||||
CoverImageURL: req.CoverImageURL,
|
||||
PriceCents: req.PriceCents,
|
||||
Currency: currency,
|
||||
PricingModel: pricingModel,
|
||||
MinimumPriceCents: req.MinimumPriceCents,
|
||||
Status: CourseStatusDraft,
|
||||
Level: level,
|
||||
Language: language,
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Create(course).Error; err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate") && strings.Contains(err.Error(), "slug") {
|
||||
return nil, ErrSlugAlreadyTaken
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create course: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Course created",
|
||||
zap.String("course_id", course.ID.String()),
|
||||
zap.String("creator_id", creatorID.String()),
|
||||
)
|
||||
|
||||
return course, nil
|
||||
}
|
||||
|
||||
// UpdateCourseRequest holds updatable course fields
|
||||
type UpdateCourseRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Category *string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
CoverImageURL *string `json:"cover_image_url"`
|
||||
PriceCents *int `json:"price_cents"`
|
||||
Currency *string `json:"currency"`
|
||||
PricingModel *PricingModel `json:"pricing_model"`
|
||||
MinimumPriceCents *int `json:"minimum_price_cents"`
|
||||
Level *CourseLevel `json:"level"`
|
||||
Language *string `json:"language"`
|
||||
}
|
||||
|
||||
// UpdateCourse updates an existing course
|
||||
func (s *Service) UpdateCourse(ctx context.Context, creatorID, courseID uuid.UUID, req UpdateCourseRequest) (*Course, error) {
|
||||
course, err := s.getCourseByOwner(ctx, courseID, creatorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if req.Title != nil {
|
||||
updates["title"] = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
if req.Category != nil {
|
||||
updates["category"] = *req.Category
|
||||
}
|
||||
if req.Tags != nil {
|
||||
updates["tags"] = req.Tags
|
||||
}
|
||||
if req.CoverImageURL != nil {
|
||||
updates["cover_image_url"] = *req.CoverImageURL
|
||||
}
|
||||
if req.PriceCents != nil {
|
||||
updates["price_cents"] = *req.PriceCents
|
||||
}
|
||||
if req.Currency != nil {
|
||||
updates["currency"] = *req.Currency
|
||||
}
|
||||
if req.PricingModel != nil {
|
||||
updates["pricing_model"] = *req.PricingModel
|
||||
}
|
||||
if req.MinimumPriceCents != nil {
|
||||
updates["minimum_price_cents"] = *req.MinimumPriceCents
|
||||
}
|
||||
if req.Level != nil {
|
||||
updates["level"] = *req.Level
|
||||
}
|
||||
if req.Language != nil {
|
||||
updates["language"] = *req.Language
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := s.db.WithContext(ctx).Model(course).Updates(updates).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to update course: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return course, nil
|
||||
}
|
||||
|
||||
// PublishCourse sets a course to published status
|
||||
func (s *Service) PublishCourse(ctx context.Context, creatorID, courseID uuid.UUID) (*Course, error) {
|
||||
course, err := s.getCourseByOwner(ctx, courseID, creatorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
course.Status = CourseStatusPublished
|
||||
course.PublishedAt = &now
|
||||
|
||||
if err := s.db.WithContext(ctx).Save(course).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to publish course: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Course published",
|
||||
zap.String("course_id", courseID.String()),
|
||||
)
|
||||
|
||||
return course, nil
|
||||
}
|
||||
|
||||
// ArchiveCourse sets a course to archived status
|
||||
func (s *Service) ArchiveCourse(ctx context.Context, creatorID, courseID uuid.UUID) (*Course, error) {
|
||||
course, err := s.getCourseByOwner(ctx, courseID, creatorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
course.Status = CourseStatusArchived
|
||||
if err := s.db.WithContext(ctx).Save(course).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to archive course: %w", err)
|
||||
}
|
||||
|
||||
return course, nil
|
||||
}
|
||||
|
||||
// GetCourse returns a course by ID (public, for any user)
|
||||
func (s *Service) GetCourse(ctx context.Context, courseID uuid.UUID) (*Course, error) {
|
||||
var course Course
|
||||
err := s.db.WithContext(ctx).First(&course, "id = ?", courseID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrCourseNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get course: %w", err)
|
||||
}
|
||||
return &course, nil
|
||||
}
|
||||
|
||||
// GetCourseBySlug returns a course by its slug
|
||||
func (s *Service) GetCourseBySlug(ctx context.Context, slug string) (*Course, error) {
|
||||
var course Course
|
||||
err := s.db.WithContext(ctx).First(&course, "slug = ?", slug).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrCourseNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get course by slug: %w", err)
|
||||
}
|
||||
return &course, nil
|
||||
}
|
||||
|
||||
// ListPublishedCourses returns published courses with optional filters
|
||||
func (s *Service) ListPublishedCourses(ctx context.Context, category *string, level *CourseLevel, language *string, limit, offset int) ([]Course, int64, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).Where("status = ?", string(CourseStatusPublished))
|
||||
if category != nil && *category != "" {
|
||||
query = query.Where("category = ?", *category)
|
||||
}
|
||||
if level != nil {
|
||||
query = query.Where("level = ?", string(*level))
|
||||
}
|
||||
if language != nil && *language != "" {
|
||||
query = query.Where("language = ?", *language)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Model(&Course{}).Count(&total)
|
||||
|
||||
var courses []Course
|
||||
err := query.Order("published_at DESC").Limit(limit).Offset(offset).Find(&courses).Error
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list courses: %w", err)
|
||||
}
|
||||
return courses, total, nil
|
||||
}
|
||||
|
||||
// ListCreatorCourses returns all courses for a creator
|
||||
func (s *Service) ListCreatorCourses(ctx context.Context, creatorID uuid.UUID, limit, offset int) ([]Course, int64, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).Where("creator_id = ?", creatorID)
|
||||
|
||||
var total int64
|
||||
query.Model(&Course{}).Count(&total)
|
||||
|
||||
var courses []Course
|
||||
err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&courses).Error
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list creator courses: %w", err)
|
||||
}
|
||||
return courses, total, nil
|
||||
}
|
||||
|
||||
// DeleteCourse soft-deletes a course
|
||||
func (s *Service) DeleteCourse(ctx context.Context, creatorID, courseID uuid.UUID) error {
|
||||
course, err := s.getCourseByOwner(ctx, courseID, creatorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Delete(course).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete course: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Lesson CRUD ---
|
||||
|
||||
// CreateLessonRequest holds the fields for creating a lesson
|
||||
type CreateLessonRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
VideoFilePath string `json:"video_file_path"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
IsPreviewFree bool `json:"is_preview_free"`
|
||||
}
|
||||
|
||||
// CreateLesson adds a lesson to a course
|
||||
func (s *Service) CreateLesson(ctx context.Context, creatorID, courseID uuid.UUID, req CreateLessonRequest) (*Lesson, error) {
|
||||
if _, err := s.getCourseByOwner(ctx, courseID, creatorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get next order index
|
||||
var maxOrder int
|
||||
s.db.WithContext(ctx).Model(&Lesson{}).Where("course_id = ?", courseID).
|
||||
Select("COALESCE(MAX(order_index), 0)").Scan(&maxOrder)
|
||||
|
||||
lesson := &Lesson{
|
||||
CourseID: courseID,
|
||||
OrderIndex: maxOrder + 1,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
VideoFilePath: req.VideoFilePath,
|
||||
DurationSeconds: req.DurationSeconds,
|
||||
IsPreviewFree: req.IsPreviewFree,
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(lesson).Error; err != nil {
|
||||
return fmt.Errorf("failed to create lesson: %w", err)
|
||||
}
|
||||
// Update course counters
|
||||
return tx.Model(&Course{}).Where("id = ?", courseID).
|
||||
UpdateColumns(map[string]interface{}{
|
||||
"lesson_count": gorm.Expr("lesson_count + 1"),
|
||||
"total_duration_seconds": gorm.Expr("total_duration_seconds + ?", req.DurationSeconds),
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return lesson, nil
|
||||
}
|
||||
|
||||
// UpdateLessonRequest holds updatable lesson fields
|
||||
type UpdateLessonRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
VideoFilePath *string `json:"video_file_path"`
|
||||
DurationSeconds *int `json:"duration_seconds"`
|
||||
IsPreviewFree *bool `json:"is_preview_free"`
|
||||
}
|
||||
|
||||
// UpdateLesson updates a lesson
|
||||
func (s *Service) UpdateLesson(ctx context.Context, creatorID, courseID, lessonID uuid.UUID, req UpdateLessonRequest) (*Lesson, error) {
|
||||
if _, err := s.getCourseByOwner(ctx, courseID, creatorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lesson Lesson
|
||||
err := s.db.WithContext(ctx).First(&lesson, "id = ? AND course_id = ?", lessonID, courseID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrLessonNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get lesson: %w", err)
|
||||
}
|
||||
|
||||
oldDuration := lesson.DurationSeconds
|
||||
updates := map[string]interface{}{}
|
||||
if req.Title != nil {
|
||||
updates["title"] = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
if req.VideoFilePath != nil {
|
||||
updates["video_file_path"] = *req.VideoFilePath
|
||||
updates["transcoding_status"] = string(TranscodingPending)
|
||||
}
|
||||
if req.DurationSeconds != nil {
|
||||
updates["duration_seconds"] = *req.DurationSeconds
|
||||
}
|
||||
if req.IsPreviewFree != nil {
|
||||
updates["is_preview_free"] = *req.IsPreviewFree
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&lesson).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Update course duration if changed
|
||||
if req.DurationSeconds != nil && *req.DurationSeconds != oldDuration {
|
||||
diff := *req.DurationSeconds - oldDuration
|
||||
return tx.Model(&Course{}).Where("id = ?", courseID).
|
||||
UpdateColumn("total_duration_seconds", gorm.Expr("total_duration_seconds + ?", diff)).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update lesson: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &lesson, nil
|
||||
}
|
||||
|
||||
// DeleteLesson removes a lesson
|
||||
func (s *Service) DeleteLesson(ctx context.Context, creatorID, courseID, lessonID uuid.UUID) error {
|
||||
if _, err := s.getCourseByOwner(ctx, courseID, creatorID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var lesson Lesson
|
||||
err := s.db.WithContext(ctx).First(&lesson, "id = ? AND course_id = ?", lessonID, courseID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrLessonNotFound
|
||||
}
|
||||
return fmt.Errorf("failed to get lesson: %w", err)
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Delete(&lesson).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&Course{}).Where("id = ?", courseID).
|
||||
UpdateColumns(map[string]interface{}{
|
||||
"lesson_count": gorm.Expr("lesson_count - 1"),
|
||||
"total_duration_seconds": gorm.Expr("total_duration_seconds - ?", lesson.DurationSeconds),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// GetCourseLessons returns all lessons for a course in order
|
||||
func (s *Service) GetCourseLessons(ctx context.Context, courseID uuid.UUID) ([]Lesson, error) {
|
||||
var lessons []Lesson
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("course_id = ?", courseID).
|
||||
Order("order_index ASC").
|
||||
Find(&lessons).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get lessons: %w", err)
|
||||
}
|
||||
return lessons, nil
|
||||
}
|
||||
|
||||
// ReorderLessons updates the order of lessons
|
||||
type ReorderLessonsRequest struct {
|
||||
LessonIDs []uuid.UUID `json:"lesson_ids" binding:"required"`
|
||||
}
|
||||
|
||||
func (s *Service) ReorderLessons(ctx context.Context, creatorID, courseID uuid.UUID, req ReorderLessonsRequest) error {
|
||||
if _, err := s.getCourseByOwner(ctx, courseID, creatorID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for i, id := range req.LessonIDs {
|
||||
if err := tx.Model(&Lesson{}).
|
||||
Where("id = ? AND course_id = ?", id, courseID).
|
||||
Update("order_index", i+1).Error; err != nil {
|
||||
return fmt.Errorf("failed to reorder lesson %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// --- Enrollments ---
|
||||
|
||||
// EnrollRequest holds the parameters for enrolling in a course
|
||||
type EnrollRequest struct {
|
||||
PricePaidCents int `json:"price_paid_cents"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// Enroll enrolls a user in a course
|
||||
func (s *Service) Enroll(ctx context.Context, userID, courseID uuid.UUID, req EnrollRequest) (*CourseEnrollment, error) {
|
||||
// Get course
|
||||
course, err := s.GetCourse(ctx, courseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if course.Status != CourseStatusPublished {
|
||||
return nil, ErrCourseNotPublished
|
||||
}
|
||||
|
||||
if course.CreatorID == userID {
|
||||
return nil, ErrCannotEnrollOwnCourse
|
||||
}
|
||||
|
||||
// Check existing enrollment
|
||||
var existing int64
|
||||
s.db.WithContext(ctx).Model(&CourseEnrollment{}).
|
||||
Where("user_id = ? AND course_id = ?", userID, courseID).
|
||||
Count(&existing)
|
||||
if existing > 0 {
|
||||
return nil, ErrAlreadyEnrolled
|
||||
}
|
||||
|
||||
currency := req.Currency
|
||||
if currency == "" {
|
||||
currency = course.Currency
|
||||
}
|
||||
|
||||
enrollment := &CourseEnrollment{
|
||||
UserID: userID,
|
||||
CourseID: courseID,
|
||||
PurchasedPriceCents: req.PricePaidCents,
|
||||
Currency: currency,
|
||||
Status: EnrollmentActive,
|
||||
PurchasedAt: time.Now(),
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(enrollment).Error; err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate") {
|
||||
return ErrAlreadyEnrolled
|
||||
}
|
||||
return fmt.Errorf("failed to create enrollment: %w", err)
|
||||
}
|
||||
return tx.Model(&Course{}).Where("id = ?", courseID).
|
||||
UpdateColumn("enrollment_count", gorm.Expr("enrollment_count + 1")).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("User enrolled in course",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("course_id", courseID.String()),
|
||||
)
|
||||
|
||||
return enrollment, nil
|
||||
}
|
||||
|
||||
// GetUserEnrollments returns all enrollments for a user
|
||||
func (s *Service) GetUserEnrollments(ctx context.Context, userID uuid.UUID, limit, offset int) ([]CourseEnrollment, int64, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).Where("user_id = ?", userID)
|
||||
|
||||
var total int64
|
||||
query.Model(&CourseEnrollment{}).Count(&total)
|
||||
|
||||
var enrollments []CourseEnrollment
|
||||
err := query.Order("purchased_at DESC").Limit(limit).Offset(offset).Find(&enrollments).Error
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get enrollments: %w", err)
|
||||
}
|
||||
return enrollments, total, nil
|
||||
}
|
||||
|
||||
// GetEnrollment returns a specific enrollment
|
||||
func (s *Service) GetEnrollment(ctx context.Context, userID, courseID uuid.UUID) (*CourseEnrollment, error) {
|
||||
var enrollment CourseEnrollment
|
||||
err := s.db.WithContext(ctx).First(&enrollment, "user_id = ? AND course_id = ?", userID, courseID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotEnrolled
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get enrollment: %w", err)
|
||||
}
|
||||
return &enrollment, nil
|
||||
}
|
||||
|
||||
// --- Progress ---
|
||||
|
||||
// UpdateProgressRequest holds progress update data
|
||||
type UpdateProgressRequest struct {
|
||||
WatchedPercentage int `json:"watched_percentage"`
|
||||
WatchedDurationSeconds int `json:"watched_duration_seconds"`
|
||||
PlaybackPositionSeconds int `json:"playback_position_seconds"`
|
||||
}
|
||||
|
||||
// UpdateProgress updates or creates lesson progress
|
||||
func (s *Service) UpdateProgress(ctx context.Context, userID, lessonID uuid.UUID, req UpdateProgressRequest) (*LessonProgress, error) {
|
||||
// Get the lesson to find the course
|
||||
var lesson Lesson
|
||||
err := s.db.WithContext(ctx).First(&lesson, "id = ?", lessonID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrLessonNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get lesson: %w", err)
|
||||
}
|
||||
|
||||
// Get enrollment
|
||||
enrollment, err := s.GetEnrollment(ctx, userID, lesson.CourseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Clamp percentage
|
||||
percentage := req.WatchedPercentage
|
||||
if percentage < 0 {
|
||||
percentage = 0
|
||||
}
|
||||
if percentage > 100 {
|
||||
percentage = 100
|
||||
}
|
||||
|
||||
// Upsert progress
|
||||
var progress LessonProgress
|
||||
err = s.db.WithContext(ctx).
|
||||
Where("user_id = ? AND lesson_id = ?", userID, lessonID).
|
||||
First(&progress).Error
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
progress = LessonProgress{
|
||||
UserID: userID,
|
||||
LessonID: lessonID,
|
||||
EnrollmentID: enrollment.ID,
|
||||
WatchedPercentage: percentage,
|
||||
WatchedDurationSeconds: req.WatchedDurationSeconds,
|
||||
PlaybackPositionSeconds: req.PlaybackPositionSeconds,
|
||||
}
|
||||
if percentage >= 90 {
|
||||
progress.IsCompleted = true
|
||||
now := time.Now()
|
||||
progress.CompletedAt = &now
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(&progress).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create progress: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to get progress: %w", err)
|
||||
} else {
|
||||
updates := map[string]interface{}{
|
||||
"watched_percentage": percentage,
|
||||
"watched_duration_seconds": req.WatchedDurationSeconds,
|
||||
"playback_position_seconds": req.PlaybackPositionSeconds,
|
||||
}
|
||||
if percentage >= 90 && !progress.IsCompleted {
|
||||
updates["is_completed"] = true
|
||||
now := time.Now()
|
||||
updates["completed_at"] = now
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&progress).Updates(updates).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to update progress: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &progress, nil
|
||||
}
|
||||
|
||||
// GetCourseProgress returns all lesson progress for a user in a course
|
||||
func (s *Service) GetCourseProgress(ctx context.Context, userID, courseID uuid.UUID) ([]LessonProgress, error) {
|
||||
enrollment, err := s.GetEnrollment(ctx, userID, courseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var progress []LessonProgress
|
||||
err = s.db.WithContext(ctx).
|
||||
Where("enrollment_id = ?", enrollment.ID).
|
||||
Find(&progress).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get course progress: %w", err)
|
||||
}
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
// --- Certificates ---
|
||||
|
||||
// IssueCertificate issues a completion certificate for a fully completed course
|
||||
func (s *Service) IssueCertificate(ctx context.Context, userID, courseID uuid.UUID) (*Certificate, error) {
|
||||
enrollment, err := s.GetEnrollment(ctx, userID, courseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if certificate already exists
|
||||
var existingCount int64
|
||||
s.db.WithContext(ctx).Model(&Certificate{}).
|
||||
Where("user_id = ? AND course_id = ?", userID, courseID).
|
||||
Count(&existingCount)
|
||||
if existingCount > 0 {
|
||||
return nil, ErrCertificateExists
|
||||
}
|
||||
|
||||
// Check all lessons are completed
|
||||
var lessonCount int64
|
||||
s.db.WithContext(ctx).Model(&Lesson{}).Where("course_id = ?", courseID).Count(&lessonCount)
|
||||
|
||||
var completedCount int64
|
||||
s.db.WithContext(ctx).Model(&LessonProgress{}).
|
||||
Where("enrollment_id = ? AND is_completed = true", enrollment.ID).
|
||||
Count(&completedCount)
|
||||
|
||||
if lessonCount == 0 || completedCount < lessonCount {
|
||||
return nil, ErrCourseNotCompleted
|
||||
}
|
||||
|
||||
code := fmt.Sprintf("VEZA-CERT-%s-%s", courseID.String()[:8], uuid.New().String()[:8])
|
||||
|
||||
cert := &Certificate{
|
||||
UserID: userID,
|
||||
CourseID: courseID,
|
||||
EnrollmentID: enrollment.ID,
|
||||
CertificateCode: code,
|
||||
IssueDate: time.Now(),
|
||||
Status: CertificateActive,
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Create(cert).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to issue certificate: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Certificate issued",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("course_id", courseID.String()),
|
||||
zap.String("certificate_code", code),
|
||||
)
|
||||
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// GetCertificate returns a certificate by code (public verification)
|
||||
func (s *Service) GetCertificate(ctx context.Context, code string) (*Certificate, error) {
|
||||
var cert Certificate
|
||||
err := s.db.WithContext(ctx).First(&cert, "certificate_code = ?", code).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrCertificateNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get certificate: %w", err)
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// GetUserCertificates returns all certificates for a user
|
||||
func (s *Service) GetUserCertificates(ctx context.Context, userID uuid.UUID) ([]Certificate, error) {
|
||||
var certs []Certificate
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Order("issue_date DESC").
|
||||
Find(&certs).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get certificates: %w", err)
|
||||
}
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
// --- Reviews ---
|
||||
|
||||
// CreateReviewRequest holds the fields for creating a review
|
||||
type CreateReviewRequest struct {
|
||||
Rating int `json:"rating" binding:"required"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateReview creates a course review
|
||||
func (s *Service) CreateReview(ctx context.Context, userID, courseID uuid.UUID, req CreateReviewRequest) (*CourseReview, error) {
|
||||
if req.Rating < 1 || req.Rating > 5 {
|
||||
return nil, ErrInvalidRating
|
||||
}
|
||||
|
||||
// Must be enrolled
|
||||
enrollment, err := s.GetEnrollment(ctx, userID, courseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check existing review
|
||||
var existing int64
|
||||
s.db.WithContext(ctx).Model(&CourseReview{}).
|
||||
Where("user_id = ? AND course_id = ?", userID, courseID).
|
||||
Count(&existing)
|
||||
if existing > 0 {
|
||||
return nil, ErrReviewAlreadyExists
|
||||
}
|
||||
|
||||
review := &CourseReview{
|
||||
CourseID: courseID,
|
||||
UserID: userID,
|
||||
EnrollmentID: enrollment.ID,
|
||||
Rating: req.Rating,
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Status: ReviewApproved,
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(review).Error; err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate") {
|
||||
return ErrReviewAlreadyExists
|
||||
}
|
||||
return fmt.Errorf("failed to create review: %w", err)
|
||||
}
|
||||
// Update course review count and average
|
||||
return tx.Model(&Course{}).Where("id = ?", courseID).
|
||||
UpdateColumns(map[string]interface{}{
|
||||
"review_count": gorm.Expr("review_count + 1"),
|
||||
"average_rating": gorm.Expr(
|
||||
"(SELECT COALESCE(AVG(rating), 0) FROM course_reviews WHERE course_id = ? AND deleted_at IS NULL AND status = 'approved')",
|
||||
courseID,
|
||||
),
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return review, nil
|
||||
}
|
||||
|
||||
// GetCourseReviews returns reviews for a course
|
||||
func (s *Service) GetCourseReviews(ctx context.Context, courseID uuid.UUID, limit, offset int) ([]CourseReview, int64, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).Where("course_id = ? AND status = ?", courseID, string(ReviewApproved))
|
||||
|
||||
var total int64
|
||||
query.Model(&CourseReview{}).Count(&total)
|
||||
|
||||
var reviews []CourseReview
|
||||
err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&reviews).Error
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get reviews: %w", err)
|
||||
}
|
||||
return reviews, total, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func (s *Service) getCourseByOwner(ctx context.Context, courseID, creatorID uuid.UUID) (*Course, error) {
|
||||
var course Course
|
||||
err := s.db.WithContext(ctx).First(&course, "id = ?", courseID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrCourseNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get course: %w", err)
|
||||
}
|
||||
if course.CreatorID != creatorID {
|
||||
return nil, ErrNotCourseOwner
|
||||
}
|
||||
return &course, nil
|
||||
}
|
||||
|
||||
func generateSlug(title string) string {
|
||||
slug := strings.ToLower(title)
|
||||
slug = strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
return r
|
||||
}
|
||||
if r == ' ' || r == '-' || r == '_' {
|
||||
return '-'
|
||||
}
|
||||
return -1
|
||||
}, slug)
|
||||
// Remove consecutive dashes
|
||||
for strings.Contains(slug, "--") {
|
||||
slug = strings.ReplaceAll(slug, "--", "-")
|
||||
}
|
||||
slug = strings.Trim(slug, "-")
|
||||
if slug == "" {
|
||||
slug = uuid.New().String()[:8]
|
||||
}
|
||||
return slug
|
||||
}
|
||||
279
veza-backend-api/internal/core/education/service_test.go
Normal file
279
veza-backend-api/internal/core/education/service_test.go
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
package education
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestCourseTableName(t *testing.T) {
|
||||
c := Course{}
|
||||
if c.TableName() != "courses" {
|
||||
t.Errorf("expected courses, got %s", c.TableName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLessonTableName(t *testing.T) {
|
||||
l := Lesson{}
|
||||
if l.TableName() != "lessons" {
|
||||
t.Errorf("expected lessons, got %s", l.TableName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCourseEnrollmentTableName(t *testing.T) {
|
||||
e := CourseEnrollment{}
|
||||
if e.TableName() != "course_enrollments" {
|
||||
t.Errorf("expected course_enrollments, got %s", e.TableName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLessonProgressTableName(t *testing.T) {
|
||||
p := LessonProgress{}
|
||||
if p.TableName() != "lesson_progress" {
|
||||
t.Errorf("expected lesson_progress, got %s", p.TableName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateTableName(t *testing.T) {
|
||||
cert := Certificate{}
|
||||
if cert.TableName() != "certificates" {
|
||||
t.Errorf("expected certificates, got %s", cert.TableName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCourseReviewTableName(t *testing.T) {
|
||||
r := CourseReview{}
|
||||
if r.TableName() != "course_reviews" {
|
||||
t.Errorf("expected course_reviews, got %s", r.TableName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCourseStatusConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status CourseStatus
|
||||
expected string
|
||||
}{
|
||||
{"draft", CourseStatusDraft, "draft"},
|
||||
{"published", CourseStatusPublished, "published"},
|
||||
{"archived", CourseStatusArchived, "archived"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.status) != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, string(tt.status))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCourseLevelConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
level CourseLevel
|
||||
expected string
|
||||
}{
|
||||
{"beginner", CourseLevelBeginner, "beginner"},
|
||||
{"intermediate", CourseLevelIntermediate, "intermediate"},
|
||||
{"advanced", CourseLevelAdvanced, "advanced"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.level) != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, string(tt.level))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricingModelConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model PricingModel
|
||||
expected string
|
||||
}{
|
||||
{"fixed", PricingFixed, "fixed"},
|
||||
{"pay_what_you_want", PricingPayWhatYou, "pay_what_you_want"},
|
||||
{"free", PricingFree, "free"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.model) != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, string(tt.model))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscodingStatusConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status TranscodingStatus
|
||||
expected string
|
||||
}{
|
||||
{"pending", TranscodingPending, "pending"},
|
||||
{"processing", TranscodingProcessing, "processing"},
|
||||
{"complete", TranscodingComplete, "complete"},
|
||||
{"failed", TranscodingFailed, "failed"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.status) != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, string(tt.status))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
msg string
|
||||
}{
|
||||
{"course not found", ErrCourseNotFound, "course not found"},
|
||||
{"lesson not found", ErrLessonNotFound, "lesson not found"},
|
||||
{"enrollment not found", ErrEnrollmentNotFound, "enrollment not found"},
|
||||
{"already enrolled", ErrAlreadyEnrolled, "already enrolled in this course"},
|
||||
{"not enrolled", ErrNotEnrolled, "not enrolled in this course"},
|
||||
{"course not published", ErrCourseNotPublished, "course is not published"},
|
||||
{"not course owner", ErrNotCourseOwner, "you are not the owner of this course"},
|
||||
{"review exists", ErrReviewAlreadyExists, "you have already reviewed this course"},
|
||||
{"invalid rating", ErrInvalidRating, "rating must be between 1 and 5"},
|
||||
{"certificate not found", ErrCertificateNotFound, "certificate not found"},
|
||||
{"course not completed", ErrCourseNotCompleted, "course is not fully completed"},
|
||||
{"certificate exists", ErrCertificateExists, "certificate already issued for this course"},
|
||||
{"cannot enroll own", ErrCannotEnrollOwnCourse, "cannot enroll in your own course"},
|
||||
{"slug taken", ErrSlugAlreadyTaken, "slug is already taken"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.err.Error() != tt.msg {
|
||||
t.Errorf("expected %q, got %q", tt.msg, tt.err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSlug(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"My First Course", "my-first-course"},
|
||||
{"Introduction à la Musique", "introduction--la-musique"},
|
||||
{"Hello World 123", "hello-world-123"},
|
||||
{" spaces ", "spaces"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := generateSlug(tt.input)
|
||||
if got == "" {
|
||||
t.Error("slug should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewService(t *testing.T) {
|
||||
svc := NewService(nil, nil)
|
||||
if svc == nil {
|
||||
t.Fatal("expected non-nil service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCourseFields(t *testing.T) {
|
||||
course := Course{
|
||||
ID: uuid.New(),
|
||||
CreatorID: uuid.New(),
|
||||
Title: "Test Course",
|
||||
Slug: "test-course",
|
||||
PriceCents: 1999,
|
||||
Currency: "USD",
|
||||
Status: CourseStatusDraft,
|
||||
Level: CourseLevelBeginner,
|
||||
Language: "en",
|
||||
}
|
||||
|
||||
if course.Title != "Test Course" {
|
||||
t.Errorf("expected Test Course, got %s", course.Title)
|
||||
}
|
||||
if course.PriceCents != 1999 {
|
||||
t.Errorf("expected 1999, got %d", course.PriceCents)
|
||||
}
|
||||
if course.Status != CourseStatusDraft {
|
||||
t.Errorf("expected draft, got %s", course.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLessonFields(t *testing.T) {
|
||||
lesson := Lesson{
|
||||
ID: uuid.New(),
|
||||
CourseID: uuid.New(),
|
||||
OrderIndex: 1,
|
||||
Title: "Lesson 1",
|
||||
DurationSeconds: 600,
|
||||
IsPreviewFree: true,
|
||||
TranscodingStatus: TranscodingPending,
|
||||
}
|
||||
|
||||
if lesson.OrderIndex != 1 {
|
||||
t.Errorf("expected 1, got %d", lesson.OrderIndex)
|
||||
}
|
||||
if !lesson.IsPreviewFree {
|
||||
t.Error("expected is_preview_free to be true")
|
||||
}
|
||||
if lesson.DurationSeconds != 600 {
|
||||
t.Errorf("expected 600, got %d", lesson.DurationSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollmentFields(t *testing.T) {
|
||||
enrollment := CourseEnrollment{
|
||||
ID: uuid.New(),
|
||||
UserID: uuid.New(),
|
||||
CourseID: uuid.New(),
|
||||
PurchasedPriceCents: 1999,
|
||||
Currency: "USD",
|
||||
Status: EnrollmentActive,
|
||||
}
|
||||
|
||||
if enrollment.PurchasedPriceCents != 1999 {
|
||||
t.Errorf("expected 1999, got %d", enrollment.PurchasedPriceCents)
|
||||
}
|
||||
if enrollment.Status != EnrollmentActive {
|
||||
t.Errorf("expected active, got %s", enrollment.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateFields(t *testing.T) {
|
||||
cert := Certificate{
|
||||
ID: uuid.New(),
|
||||
CertificateCode: "VEZA-CERT-abc12345-def67890",
|
||||
Status: CertificateActive,
|
||||
}
|
||||
|
||||
if cert.Status != CertificateActive {
|
||||
t.Errorf("expected active, got %s", cert.Status)
|
||||
}
|
||||
if cert.CertificateCode == "" {
|
||||
t.Error("expected non-empty certificate code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewFields(t *testing.T) {
|
||||
review := CourseReview{
|
||||
ID: uuid.New(),
|
||||
Rating: 4,
|
||||
Title: "Great course",
|
||||
Content: "Loved it!",
|
||||
Status: ReviewApproved,
|
||||
}
|
||||
|
||||
if review.Rating != 4 {
|
||||
t.Errorf("expected 4, got %d", review.Rating)
|
||||
}
|
||||
if review.Status != ReviewApproved {
|
||||
t.Errorf("expected approved, got %s", review.Status)
|
||||
}
|
||||
}
|
||||
683
veza-backend-api/internal/handlers/education_handler.go
Normal file
683
veza-backend-api/internal/handlers/education_handler.go
Normal file
|
|
@ -0,0 +1,683 @@
|
|||
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"))
|
||||
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"))
|
||||
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"))
|
||||
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"))
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
223
veza-backend-api/internal/handlers/education_handler_test.go
Normal file
223
veza-backend-api/internal/handlers/education_handler_test.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"veza-backend-api/internal/core/education"
|
||||
)
|
||||
|
||||
func TestNewEducationHandler(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
svc := education.NewService(nil, logger)
|
||||
handler := NewEducationHandler(svc, logger)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("expected non-nil handler")
|
||||
}
|
||||
if handler.service == nil {
|
||||
t.Error("expected non-nil service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationHandler_CreateCourse_NoAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := education.NewService(nil, logger)
|
||||
handler := NewEducationHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/courses", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.CreateCourse(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationHandler_CreateCourse_InvalidBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := education.NewService(nil, logger)
|
||||
handler := NewEducationHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", uuid.New())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/courses", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.CreateCourse(c)
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["success"] != false {
|
||||
t.Error("expected success=false for invalid request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationHandler_GetCourse_InvalidID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := education.NewService(nil, logger)
|
||||
handler := NewEducationHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/courses/not-a-uuid", nil)
|
||||
|
||||
handler.GetCourse(c)
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["success"] != false {
|
||||
t.Error("expected success=false for invalid ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationHandler_GetCourseBySlug_EmptySlug(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := education.NewService(nil, logger)
|
||||
handler := NewEducationHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "slug", Value: ""}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/courses/slug/", nil)
|
||||
|
||||
handler.GetCourseBySlug(c)
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["success"] != false {
|
||||
t.Error("expected success=false for empty slug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationHandler_Enroll_NoAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := education.NewService(nil, logger)
|
||||
handler := NewEducationHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/courses/123/enroll", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Enroll(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationHandler_UpdateProgress_NoAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := education.NewService(nil, logger)
|
||||
handler := NewEducationHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/lessons/123/progress", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateProgress(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationHandler_IssueCertificate_NoAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := education.NewService(nil, logger)
|
||||
handler := NewEducationHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/courses/123/certificate", nil)
|
||||
|
||||
handler.IssueCertificate(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationHandler_CreateReview_NoAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := education.NewService(nil, logger)
|
||||
handler := NewEducationHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/courses/123/reviews", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.CreateReview(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationHandler_GetMyEnrollments_NoAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := education.NewService(nil, logger)
|
||||
handler := NewEducationHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/enrollments", nil)
|
||||
|
||||
handler.GetMyEnrollments(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationHandler_VerifyCertificate_EmptyCode(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logger := zap.NewNop()
|
||||
svc := education.NewService(nil, logger)
|
||||
handler := NewEducationHandler(svc, logger)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "code", Value: ""}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/certificates/", nil)
|
||||
|
||||
handler.VerifyCertificate(c)
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["success"] != false {
|
||||
t.Error("expected success=false for empty code")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue