- 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>
907 lines
26 KiB
Go
907 lines
26 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
|
|
}
|
|
|
|
// --- 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
|
|
}
|