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 } // ============================================================================ // Enrollment Operations // ============================================================================ // CreateEnrollment creates a new enrollment func (s *Store) CreateEnrollment(ctx context.Context, enrollment *Enrollment) error { enrollment.ID = uuid.New() enrollment.CreatedAt = time.Now().UTC() enrollment.UpdatedAt = enrollment.CreatedAt if enrollment.Status == "" { enrollment.Status = EnrollmentStatusNotStarted } _, err := s.pool.Exec(ctx, ` INSERT INTO academy_enrollments ( id, tenant_id, course_id, user_id, user_name, user_email, status, progress_percent, current_lesson_index, started_at, completed_at, deadline, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 ) `, enrollment.ID, enrollment.TenantID, enrollment.CourseID, enrollment.UserID, enrollment.UserName, enrollment.UserEmail, string(enrollment.Status), enrollment.ProgressPercent, enrollment.CurrentLessonIndex, enrollment.StartedAt, enrollment.CompletedAt, enrollment.Deadline, enrollment.CreatedAt, enrollment.UpdatedAt, ) return err } // GetEnrollment retrieves an enrollment by ID func (s *Store) GetEnrollment(ctx context.Context, id uuid.UUID) (*Enrollment, error) { var enrollment Enrollment var status string err := s.pool.QueryRow(ctx, ` SELECT id, tenant_id, course_id, user_id, user_name, user_email, status, progress_percent, current_lesson_index, started_at, completed_at, deadline, created_at, updated_at FROM academy_enrollments WHERE id = $1 `, id).Scan( &enrollment.ID, &enrollment.TenantID, &enrollment.CourseID, &enrollment.UserID, &enrollment.UserName, &enrollment.UserEmail, &status, &enrollment.ProgressPercent, &enrollment.CurrentLessonIndex, &enrollment.StartedAt, &enrollment.CompletedAt, &enrollment.Deadline, &enrollment.CreatedAt, &enrollment.UpdatedAt, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } enrollment.Status = EnrollmentStatus(status) return &enrollment, nil } // ListEnrollments lists enrollments for a tenant with optional filters func (s *Store) ListEnrollments(ctx context.Context, tenantID uuid.UUID, filters *EnrollmentFilters) ([]Enrollment, int, error) { // Count query countQuery := "SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1" countArgs := []interface{}{tenantID} countArgIdx := 2 // List query query := ` SELECT id, tenant_id, course_id, user_id, user_name, user_email, status, progress_percent, current_lesson_index, started_at, completed_at, deadline, created_at, updated_at FROM academy_enrollments WHERE tenant_id = $1` args := []interface{}{tenantID} argIdx := 2 if filters != nil { if filters.CourseID != nil { query += fmt.Sprintf(" AND course_id = $%d", argIdx) args = append(args, *filters.CourseID) argIdx++ countQuery += fmt.Sprintf(" AND course_id = $%d", countArgIdx) countArgs = append(countArgs, *filters.CourseID) countArgIdx++ } if filters.UserID != nil { query += fmt.Sprintf(" AND user_id = $%d", argIdx) args = append(args, *filters.UserID) argIdx++ countQuery += fmt.Sprintf(" AND user_id = $%d", countArgIdx) countArgs = append(countArgs, *filters.UserID) countArgIdx++ } if filters.Status != "" { query += fmt.Sprintf(" AND status = $%d", argIdx) args = append(args, string(filters.Status)) argIdx++ countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx) countArgs = append(countArgs, string(filters.Status)) 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 enrollments []Enrollment for rows.Next() { var enrollment Enrollment var status string err := rows.Scan( &enrollment.ID, &enrollment.TenantID, &enrollment.CourseID, &enrollment.UserID, &enrollment.UserName, &enrollment.UserEmail, &status, &enrollment.ProgressPercent, &enrollment.CurrentLessonIndex, &enrollment.StartedAt, &enrollment.CompletedAt, &enrollment.Deadline, &enrollment.CreatedAt, &enrollment.UpdatedAt, ) if err != nil { return nil, 0, err } enrollment.Status = EnrollmentStatus(status) enrollments = append(enrollments, enrollment) } if enrollments == nil { enrollments = []Enrollment{} } return enrollments, total, nil } // UpdateEnrollmentProgress updates the progress for an enrollment func (s *Store) UpdateEnrollmentProgress(ctx context.Context, id uuid.UUID, progress int, currentLesson int) error { now := time.Now().UTC() // If progress > 0, set started_at if not already set and update status to in_progress _, err := s.pool.Exec(ctx, ` UPDATE academy_enrollments SET progress_percent = $2, current_lesson_index = $3, status = CASE WHEN $2 >= 100 THEN 'completed' WHEN $2 > 0 THEN 'in_progress' ELSE status END, started_at = CASE WHEN started_at IS NULL AND $2 > 0 THEN $4 ELSE started_at END, completed_at = CASE WHEN $2 >= 100 THEN $4 ELSE completed_at END, updated_at = $4 WHERE id = $1 `, id, progress, currentLesson, now) return err } // CompleteEnrollment marks an enrollment as completed func (s *Store) CompleteEnrollment(ctx context.Context, id uuid.UUID) error { now := time.Now().UTC() _, err := s.pool.Exec(ctx, ` UPDATE academy_enrollments SET status = 'completed', progress_percent = 100, completed_at = $2, updated_at = $2 WHERE id = $1 `, id, now) return err } // ============================================================================ // Certificate Operations // ============================================================================ // GetCertificate retrieves a certificate by ID func (s *Store) GetCertificate(ctx context.Context, id uuid.UUID) (*Certificate, error) { var cert Certificate err := s.pool.QueryRow(ctx, ` SELECT id, enrollment_id, user_name, course_title, issued_at, valid_until, pdf_url FROM academy_certificates WHERE id = $1 `, id).Scan( &cert.ID, &cert.EnrollmentID, &cert.UserName, &cert.CourseTitle, &cert.IssuedAt, &cert.ValidUntil, &cert.PDFURL, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &cert, nil } // GetCertificateByEnrollment retrieves a certificate by enrollment ID func (s *Store) GetCertificateByEnrollment(ctx context.Context, enrollmentID uuid.UUID) (*Certificate, error) { var cert Certificate err := s.pool.QueryRow(ctx, ` SELECT id, enrollment_id, user_name, course_title, issued_at, valid_until, pdf_url FROM academy_certificates WHERE enrollment_id = $1 `, enrollmentID).Scan( &cert.ID, &cert.EnrollmentID, &cert.UserName, &cert.CourseTitle, &cert.IssuedAt, &cert.ValidUntil, &cert.PDFURL, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &cert, nil } // CreateCertificate creates a new certificate func (s *Store) CreateCertificate(ctx context.Context, cert *Certificate) error { cert.ID = uuid.New() cert.IssuedAt = time.Now().UTC() _, err := s.pool.Exec(ctx, ` INSERT INTO academy_certificates ( id, enrollment_id, user_name, course_title, issued_at, valid_until, pdf_url ) VALUES ( $1, $2, $3, $4, $5, $6, $7 ) `, cert.ID, cert.EnrollmentID, cert.UserName, cert.CourseTitle, cert.IssuedAt, cert.ValidUntil, cert.PDFURL, ) return err } // ============================================================================ // Statistics // ============================================================================ // GetStatistics returns aggregated academy statistics for a tenant func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*AcademyStatistics, error) { stats := &AcademyStatistics{} // Total active courses s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM academy_courses WHERE tenant_id = $1 AND is_active = true", tenantID).Scan(&stats.TotalCourses) // Total enrollments s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1", tenantID).Scan(&stats.TotalEnrollments) // Completion rate if stats.TotalEnrollments > 0 { var completed int s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1 AND status = 'completed'", tenantID).Scan(&completed) stats.CompletionRate = float64(completed) / float64(stats.TotalEnrollments) * 100 } // Overdue count (past deadline, not completed) s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1 AND status NOT IN ('completed', 'expired') AND deadline IS NOT NULL AND deadline < NOW()`, tenantID).Scan(&stats.OverdueCount) // Average completion days s.pool.QueryRow(ctx, `SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) / 86400), 0) FROM academy_enrollments WHERE tenant_id = $1 AND status = 'completed' AND started_at IS NOT NULL AND completed_at IS NOT NULL`, tenantID).Scan(&stats.AvgCompletionDays) return stats, nil }