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 } // --- Video Upload --- // UploadLessonVideoResult holds the result of a lesson video upload type UploadLessonVideoResult struct { LessonID uuid.UUID `json:"lesson_id"` VideoFilePath string `json:"video_file_path"` Status string `json:"transcoding_status"` } // SetLessonVideoPath sets the video file path on a lesson and marks it for transcoding func (s *Service) SetLessonVideoPath(ctx context.Context, creatorID, courseID, lessonID uuid.UUID, videoPath string, durationSeconds int) (*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{}{ "video_file_path": videoPath, "transcoding_status": string(TranscodingPending), } if durationSeconds > 0 { updates["duration_seconds"] = durationSeconds } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Model(&lesson).Updates(updates).Error; err != nil { return err } if durationSeconds > 0 && durationSeconds != oldDuration { diff := 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 set lesson video: %w", err) } s.logger.Info("Lesson video path set", zap.String("lesson_id", lessonID.String()), zap.String("video_path", videoPath), ) // Re-read to get updated fields s.db.WithContext(ctx).First(&lesson, "id = ?", lessonID) return &lesson, nil } // UpdateLessonTranscoding updates the transcoding status and HLS URL of a lesson func (s *Service) UpdateLessonTranscoding(ctx context.Context, lessonID uuid.UUID, status TranscodingStatus, hlsURL string, durationSeconds int) error { updates := map[string]interface{}{ "transcoding_status": string(status), } if hlsURL != "" { updates["hls_master_playlist_url"] = hlsURL } err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Model(&Lesson{}).Where("id = ?", lessonID).Updates(updates).Error; err != nil { return err } // Update course duration if provided if durationSeconds > 0 { var lesson Lesson if err := tx.First(&lesson, "id = ?", lessonID).Error; err != nil { return nil // lesson not found is not critical here } if lesson.DurationSeconds != durationSeconds { diff := durationSeconds - lesson.DurationSeconds tx.Model(&Lesson{}).Where("id = ?", lessonID).Update("duration_seconds", durationSeconds) return tx.Model(&Course{}).Where("id = ?", lesson.CourseID). UpdateColumn("total_duration_seconds", gorm.Expr("total_duration_seconds + ?", diff)).Error } } return nil }) if err != nil { return fmt.Errorf("failed to update lesson transcoding: %w", err) } s.logger.Info("Lesson transcoding updated", zap.String("lesson_id", lessonID.String()), zap.String("status", string(status)), ) return 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 }