package academy import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Store handles academy data persistence type Store struct { pool *pgxpool.Pool } // NewStore creates a new academy store func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // ============================================================================ // Course CRUD Operations // ============================================================================ // CreateCourse creates a new course func (s *Store) CreateCourse(ctx context.Context, course *Course) error { course.ID = uuid.New() course.CreatedAt = time.Now().UTC() course.UpdatedAt = course.CreatedAt if !course.IsActive { course.IsActive = true } requiredForRoles, _ := json.Marshal(course.RequiredForRoles) _, err := s.pool.Exec(ctx, ` INSERT INTO academy_courses ( id, tenant_id, title, description, category, duration_minutes, required_for_roles, is_active, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) `, course.ID, course.TenantID, course.Title, course.Description, string(course.Category), course.DurationMinutes, requiredForRoles, course.IsActive, course.CreatedAt, course.UpdatedAt, ) return err } // GetCourse retrieves a course by ID func (s *Store) GetCourse(ctx context.Context, id uuid.UUID) (*Course, error) { var course Course var category string var requiredForRoles []byte err := s.pool.QueryRow(ctx, ` SELECT id, tenant_id, title, description, category, duration_minutes, required_for_roles, is_active, created_at, updated_at FROM academy_courses WHERE id = $1 `, id).Scan( &course.ID, &course.TenantID, &course.Title, &course.Description, &category, &course.DurationMinutes, &requiredForRoles, &course.IsActive, &course.CreatedAt, &course.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } course.Category = CourseCategory(category) json.Unmarshal(requiredForRoles, &course.RequiredForRoles) if course.RequiredForRoles == nil { course.RequiredForRoles = []string{} } // Load lessons for this course lessons, err := s.ListLessons(ctx, course.ID) if err != nil { return nil, err } course.Lessons = lessons return &course, nil } // ListCourses lists courses for a tenant with optional filters func (s *Store) ListCourses(ctx context.Context, tenantID uuid.UUID, filters *CourseFilters) ([]Course, int, error) { // Count query countQuery := "SELECT COUNT(*) FROM academy_courses WHERE tenant_id = $1" countArgs := []interface{}{tenantID} countArgIdx := 2 // List query query := ` SELECT id, tenant_id, title, description, category, duration_minutes, required_for_roles, is_active, created_at, updated_at FROM academy_courses WHERE tenant_id = $1` args := []interface{}{tenantID} argIdx := 2 if filters != nil { if filters.Category != "" { query += fmt.Sprintf(" AND category = $%d", argIdx) args = append(args, string(filters.Category)) argIdx++ countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx) countArgs = append(countArgs, string(filters.Category)) countArgIdx++ } if filters.IsActive != nil { query += fmt.Sprintf(" AND is_active = $%d", argIdx) args = append(args, *filters.IsActive) argIdx++ countQuery += fmt.Sprintf(" AND is_active = $%d", countArgIdx) countArgs = append(countArgs, *filters.IsActive) countArgIdx++ } if filters.Search != "" { query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx) args = append(args, "%"+filters.Search+"%") argIdx++ countQuery += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", countArgIdx, countArgIdx) countArgs = append(countArgs, "%"+filters.Search+"%") countArgIdx++ } } // Get total count var total int err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total) if err != nil { return nil, 0, err } query += " ORDER BY created_at DESC" if filters != nil && filters.Limit > 0 { query += fmt.Sprintf(" LIMIT $%d", argIdx) args = append(args, filters.Limit) argIdx++ if filters.Offset > 0 { query += fmt.Sprintf(" OFFSET $%d", argIdx) args = append(args, filters.Offset) argIdx++ } } rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() var courses []Course for rows.Next() { var course Course var category string var requiredForRoles []byte err := rows.Scan( &course.ID, &course.TenantID, &course.Title, &course.Description, &category, &course.DurationMinutes, &requiredForRoles, &course.IsActive, &course.CreatedAt, &course.UpdatedAt, ) if err != nil { return nil, 0, err } course.Category = CourseCategory(category) json.Unmarshal(requiredForRoles, &course.RequiredForRoles) if course.RequiredForRoles == nil { course.RequiredForRoles = []string{} } courses = append(courses, course) } if courses == nil { courses = []Course{} } return courses, total, nil } // UpdateCourse updates a course func (s *Store) UpdateCourse(ctx context.Context, course *Course) error { course.UpdatedAt = time.Now().UTC() requiredForRoles, _ := json.Marshal(course.RequiredForRoles) _, err := s.pool.Exec(ctx, ` UPDATE academy_courses SET title = $2, description = $3, category = $4, duration_minutes = $5, required_for_roles = $6, is_active = $7, updated_at = $8 WHERE id = $1 `, course.ID, course.Title, course.Description, string(course.Category), course.DurationMinutes, requiredForRoles, course.IsActive, course.UpdatedAt, ) return err } // DeleteCourse deletes a course and its related data (via CASCADE) func (s *Store) DeleteCourse(ctx context.Context, id uuid.UUID) error { _, err := s.pool.Exec(ctx, "DELETE FROM academy_courses WHERE id = $1", id) return err } // ============================================================================ // Lesson Operations // ============================================================================ // CreateLesson creates a new lesson func (s *Store) CreateLesson(ctx context.Context, lesson *Lesson) error { lesson.ID = uuid.New() quizQuestions, _ := json.Marshal(lesson.QuizQuestions) _, err := s.pool.Exec(ctx, ` INSERT INTO academy_lessons ( id, course_id, title, description, lesson_type, content_url, duration_minutes, order_index, quiz_questions ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9 ) `, lesson.ID, lesson.CourseID, lesson.Title, lesson.Description, string(lesson.LessonType), lesson.ContentURL, lesson.DurationMinutes, lesson.OrderIndex, quizQuestions, ) return err } // ListLessons lists lessons for a course ordered by order_index func (s *Store) ListLessons(ctx context.Context, courseID uuid.UUID) ([]Lesson, error) { rows, err := s.pool.Query(ctx, ` SELECT id, course_id, title, description, lesson_type, content_url, duration_minutes, order_index, quiz_questions FROM academy_lessons WHERE course_id = $1 ORDER BY order_index ASC `, courseID) if err != nil { return nil, err } defer rows.Close() var lessons []Lesson for rows.Next() { var lesson Lesson var lessonType string var quizQuestions []byte err := rows.Scan( &lesson.ID, &lesson.CourseID, &lesson.Title, &lesson.Description, &lessonType, &lesson.ContentURL, &lesson.DurationMinutes, &lesson.OrderIndex, &quizQuestions, ) if err != nil { return nil, err } lesson.LessonType = LessonType(lessonType) json.Unmarshal(quizQuestions, &lesson.QuizQuestions) if lesson.QuizQuestions == nil { lesson.QuizQuestions = []QuizQuestion{} } lessons = append(lessons, lesson) } if lessons == nil { lessons = []Lesson{} } return lessons, nil } // GetLesson retrieves a single lesson by ID func (s *Store) GetLesson(ctx context.Context, id uuid.UUID) (*Lesson, error) { var lesson Lesson var lessonType string var quizQuestions []byte err := s.pool.QueryRow(ctx, ` SELECT id, course_id, title, description, lesson_type, content_url, duration_minutes, order_index, quiz_questions FROM academy_lessons WHERE id = $1 `, id).Scan( &lesson.ID, &lesson.CourseID, &lesson.Title, &lesson.Description, &lessonType, &lesson.ContentURL, &lesson.DurationMinutes, &lesson.OrderIndex, &quizQuestions, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } lesson.LessonType = LessonType(lessonType) json.Unmarshal(quizQuestions, &lesson.QuizQuestions) if lesson.QuizQuestions == nil { lesson.QuizQuestions = []QuizQuestion{} } return &lesson, nil } // UpdateLesson updates a lesson's content, title, and quiz questions func (s *Store) UpdateLesson(ctx context.Context, lesson *Lesson) error { quizQuestions, _ := json.Marshal(lesson.QuizQuestions) _, err := s.pool.Exec(ctx, ` UPDATE academy_lessons SET title = $2, description = $3, content_url = $4, duration_minutes = $5, quiz_questions = $6 WHERE id = $1 `, lesson.ID, lesson.Title, lesson.Description, lesson.ContentURL, lesson.DurationMinutes, quizQuestions, ) return err }