veza/veza-backend-api/internal/core/education/service.go
senke a1000ce7fb style(backend): gofmt -w on 85 files (whitespace only)
backend-ci.yml's `test -z "$(gofmt -l .)"` strict gate (added in
13c21ac11) failed on a backlog of unformatted files. None of the
85 files in this commit had been edited since the gate was added
because no push touched veza-backend-api/** in between, so the
gate never fired until today's CI fixes triggered it.

The diff is exclusively whitespace alignment in struct literals
and trailing-space comments. `go build ./...` and the full test
suite (with VEZA_SKIP_INTEGRATION=1 -short) pass identically.
2026-04-14 12:22:14 +02:00

1005 lines
30 KiB
Go

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
}