package db import ( "fmt" "sort" "strings" "sync" "time" ) // AcademyMemStore provides in-memory storage for academy data type AcademyMemStore struct { mu sync.RWMutex courses map[string]*AcademyCourseRow lessons map[string]*AcademyLessonRow quizQuestions map[string]*AcademyQuizQuestionRow enrollments map[string]*AcademyEnrollmentRow certificates map[string]*AcademyCertificateRow lessonProgress map[string]*AcademyLessonProgressRow } // Row types matching the DB schema type AcademyCourseRow struct { ID string TenantID string Title string Description string Category string PassingScore int DurationMinutes int RequiredForRoles []string Status string CreatedAt time.Time UpdatedAt time.Time } type AcademyLessonRow struct { ID string CourseID string Title string Type string ContentMarkdown string VideoURL string AudioURL string SortOrder int DurationMinutes int CreatedAt time.Time UpdatedAt time.Time } type AcademyQuizQuestionRow struct { ID string LessonID string Question string Options []string CorrectOptionIndex int Explanation string SortOrder int CreatedAt time.Time } type AcademyEnrollmentRow struct { ID string TenantID string CourseID string UserID string UserName string UserEmail string Status string Progress int StartedAt time.Time CompletedAt *time.Time CertificateID string Deadline time.Time CreatedAt time.Time UpdatedAt time.Time } type AcademyCertificateRow struct { ID string TenantID string EnrollmentID string CourseID string UserID string UserName string CourseName string Score int IssuedAt time.Time ValidUntil time.Time PdfURL string } type AcademyLessonProgressRow struct { ID string EnrollmentID string LessonID string Completed bool QuizScore *int CompletedAt *time.Time } type AcademyStatisticsRow struct { TotalCourses int TotalEnrollments int CompletionRate float64 OverdueCount int ByCategory map[string]int ByStatus map[string]int } func NewAcademyMemStore() *AcademyMemStore { return &AcademyMemStore{ courses: make(map[string]*AcademyCourseRow), lessons: make(map[string]*AcademyLessonRow), quizQuestions: make(map[string]*AcademyQuizQuestionRow), enrollments: make(map[string]*AcademyEnrollmentRow), certificates: make(map[string]*AcademyCertificateRow), lessonProgress: make(map[string]*AcademyLessonProgressRow), } } // generateID creates a simple unique ID func generateID() string { return fmt.Sprintf("%d", time.Now().UnixNano()) } // --------------------------------------------------------------------------- // Course CRUD // --------------------------------------------------------------------------- // ListCourses returns all courses for a tenant, sorted by UpdatedAt DESC. func (s *AcademyMemStore) ListCourses(tenantID string) []*AcademyCourseRow { s.mu.RLock() defer s.mu.RUnlock() var result []*AcademyCourseRow for _, c := range s.courses { if c.TenantID == tenantID { result = append(result, c) } } sort.Slice(result, func(i, j int) bool { return result[i].UpdatedAt.After(result[j].UpdatedAt) }) return result } // GetCourse retrieves a single course by ID. func (s *AcademyMemStore) GetCourse(id string) (*AcademyCourseRow, error) { s.mu.RLock() defer s.mu.RUnlock() c, ok := s.courses[id] if !ok { return nil, fmt.Errorf("course not found: %s", id) } return c, nil } // CreateCourse inserts a new course with auto-generated ID and timestamps. func (s *AcademyMemStore) CreateCourse(row *AcademyCourseRow) *AcademyCourseRow { s.mu.Lock() defer s.mu.Unlock() now := time.Now() row.ID = generateID() row.CreatedAt = now row.UpdatedAt = now s.courses[row.ID] = row return row } // UpdateCourse partially updates a course. Supported keys: Title, Description, // Category, PassingScore, DurationMinutes, RequiredForRoles, Status. func (s *AcademyMemStore) UpdateCourse(id string, updates map[string]interface{}) (*AcademyCourseRow, error) { s.mu.Lock() defer s.mu.Unlock() c, ok := s.courses[id] if !ok { return nil, fmt.Errorf("course not found: %s", id) } for k, v := range updates { switch strings.ToLower(k) { case "title": if val, ok := v.(string); ok { c.Title = val } case "description": if val, ok := v.(string); ok { c.Description = val } case "category": if val, ok := v.(string); ok { c.Category = val } case "passingscore", "passing_score": switch val := v.(type) { case int: c.PassingScore = val case float64: c.PassingScore = int(val) } case "durationminutes", "duration_minutes": switch val := v.(type) { case int: c.DurationMinutes = val case float64: c.DurationMinutes = int(val) } case "requiredforroles", "required_for_roles": if val, ok := v.([]string); ok { c.RequiredForRoles = val } case "status": if val, ok := v.(string); ok { c.Status = val } } } c.UpdatedAt = time.Now() return c, nil } // DeleteCourse removes a course and all related lessons, quiz questions, // enrollments, certificates, and lesson progress. func (s *AcademyMemStore) DeleteCourse(id string) error { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.courses[id]; !ok { return fmt.Errorf("course not found: %s", id) } // Collect lesson IDs for this course lessonIDs := make(map[string]bool) for lid, l := range s.lessons { if l.CourseID == id { lessonIDs[lid] = true } } // Delete quiz questions belonging to those lessons for qid, q := range s.quizQuestions { if lessonIDs[q.LessonID] { delete(s.quizQuestions, qid) } } // Delete lessons for lid := range lessonIDs { delete(s.lessons, lid) } // Collect enrollment IDs for this course enrollmentIDs := make(map[string]bool) for eid, e := range s.enrollments { if e.CourseID == id { enrollmentIDs[eid] = true } } // Delete lesson progress belonging to those enrollments for pid, p := range s.lessonProgress { if enrollmentIDs[p.EnrollmentID] { delete(s.lessonProgress, pid) } } // Delete certificates belonging to those enrollments for cid, cert := range s.certificates { if cert.CourseID == id { delete(s.certificates, cid) } } // Delete enrollments for eid := range enrollmentIDs { delete(s.enrollments, eid) } // Delete the course itself delete(s.courses, id) return nil } // --------------------------------------------------------------------------- // Lesson CRUD // --------------------------------------------------------------------------- // ListLessons returns all lessons for a course, sorted by SortOrder ASC. func (s *AcademyMemStore) ListLessons(courseID string) []*AcademyLessonRow { s.mu.RLock() defer s.mu.RUnlock() var result []*AcademyLessonRow for _, l := range s.lessons { if l.CourseID == courseID { result = append(result, l) } } sort.Slice(result, func(i, j int) bool { return result[i].SortOrder < result[j].SortOrder }) return result } // GetLesson retrieves a single lesson by ID. func (s *AcademyMemStore) GetLesson(id string) (*AcademyLessonRow, error) { s.mu.RLock() defer s.mu.RUnlock() l, ok := s.lessons[id] if !ok { return nil, fmt.Errorf("lesson not found: %s", id) } return l, nil } // CreateLesson inserts a new lesson with auto-generated ID and timestamps. func (s *AcademyMemStore) CreateLesson(row *AcademyLessonRow) *AcademyLessonRow { s.mu.Lock() defer s.mu.Unlock() now := time.Now() row.ID = generateID() row.CreatedAt = now row.UpdatedAt = now s.lessons[row.ID] = row return row } // UpdateLesson partially updates a lesson. Supported keys: Title, Type, // ContentMarkdown, VideoURL, AudioURL, SortOrder, DurationMinutes. func (s *AcademyMemStore) UpdateLesson(id string, updates map[string]interface{}) (*AcademyLessonRow, error) { s.mu.Lock() defer s.mu.Unlock() l, ok := s.lessons[id] if !ok { return nil, fmt.Errorf("lesson not found: %s", id) } for k, v := range updates { switch strings.ToLower(k) { case "title": if val, ok := v.(string); ok { l.Title = val } case "type": if val, ok := v.(string); ok { l.Type = val } case "contentmarkdown", "content_markdown": if val, ok := v.(string); ok { l.ContentMarkdown = val } case "videourl", "video_url": if val, ok := v.(string); ok { l.VideoURL = val } case "audiourl", "audio_url": if val, ok := v.(string); ok { l.AudioURL = val } case "sortorder", "sort_order": switch val := v.(type) { case int: l.SortOrder = val case float64: l.SortOrder = int(val) } case "durationminutes", "duration_minutes": switch val := v.(type) { case int: l.DurationMinutes = val case float64: l.DurationMinutes = int(val) } } } l.UpdatedAt = time.Now() return l, nil } // DeleteLesson removes a lesson and its quiz questions. func (s *AcademyMemStore) DeleteLesson(id string) error { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.lessons[id]; !ok { return fmt.Errorf("lesson not found: %s", id) } // Delete quiz questions belonging to this lesson for qid, q := range s.quizQuestions { if q.LessonID == id { delete(s.quizQuestions, qid) } } delete(s.lessons, id) return nil } // --------------------------------------------------------------------------- // Quiz Questions // --------------------------------------------------------------------------- // ListQuizQuestions returns all quiz questions for a lesson, sorted by SortOrder ASC. func (s *AcademyMemStore) ListQuizQuestions(lessonID string) []*AcademyQuizQuestionRow { s.mu.RLock() defer s.mu.RUnlock() var result []*AcademyQuizQuestionRow for _, q := range s.quizQuestions { if q.LessonID == lessonID { result = append(result, q) } } sort.Slice(result, func(i, j int) bool { return result[i].SortOrder < result[j].SortOrder }) return result } // CreateQuizQuestion inserts a new quiz question with auto-generated ID and timestamp. func (s *AcademyMemStore) CreateQuizQuestion(row *AcademyQuizQuestionRow) *AcademyQuizQuestionRow { s.mu.Lock() defer s.mu.Unlock() row.ID = generateID() row.CreatedAt = time.Now() s.quizQuestions[row.ID] = row return row } // --------------------------------------------------------------------------- // Enrollments // --------------------------------------------------------------------------- // ListEnrollments returns enrollments filtered by tenantID and optionally by courseID. // If courseID is empty, all enrollments for the tenant are returned. func (s *AcademyMemStore) ListEnrollments(tenantID string, courseID string) []*AcademyEnrollmentRow { s.mu.RLock() defer s.mu.RUnlock() var result []*AcademyEnrollmentRow for _, e := range s.enrollments { if e.TenantID != tenantID { continue } if courseID != "" && e.CourseID != courseID { continue } result = append(result, e) } sort.Slice(result, func(i, j int) bool { return result[i].UpdatedAt.After(result[j].UpdatedAt) }) return result } // GetEnrollment retrieves a single enrollment by ID. func (s *AcademyMemStore) GetEnrollment(id string) (*AcademyEnrollmentRow, error) { s.mu.RLock() defer s.mu.RUnlock() e, ok := s.enrollments[id] if !ok { return nil, fmt.Errorf("enrollment not found: %s", id) } return e, nil } // CreateEnrollment inserts a new enrollment with auto-generated ID and timestamps. func (s *AcademyMemStore) CreateEnrollment(row *AcademyEnrollmentRow) *AcademyEnrollmentRow { s.mu.Lock() defer s.mu.Unlock() now := time.Now() row.ID = generateID() row.CreatedAt = now row.UpdatedAt = now if row.StartedAt.IsZero() { row.StartedAt = now } s.enrollments[row.ID] = row return row } // UpdateEnrollment partially updates an enrollment. Supported keys: Status, // Progress, CompletedAt, CertificateID, Deadline. func (s *AcademyMemStore) UpdateEnrollment(id string, updates map[string]interface{}) (*AcademyEnrollmentRow, error) { s.mu.Lock() defer s.mu.Unlock() e, ok := s.enrollments[id] if !ok { return nil, fmt.Errorf("enrollment not found: %s", id) } for k, v := range updates { switch strings.ToLower(k) { case "status": if val, ok := v.(string); ok { e.Status = val } case "progress": switch val := v.(type) { case int: e.Progress = val case float64: e.Progress = int(val) } case "completedat", "completed_at": if val, ok := v.(*time.Time); ok { e.CompletedAt = val } else if val, ok := v.(time.Time); ok { e.CompletedAt = &val } case "certificateid", "certificate_id": if val, ok := v.(string); ok { e.CertificateID = val } case "deadline": if val, ok := v.(time.Time); ok { e.Deadline = val } } } e.UpdatedAt = time.Now() return e, nil } // --------------------------------------------------------------------------- // Certificates // --------------------------------------------------------------------------- // GetCertificate retrieves a certificate by ID. func (s *AcademyMemStore) GetCertificate(id string) (*AcademyCertificateRow, error) { s.mu.RLock() defer s.mu.RUnlock() cert, ok := s.certificates[id] if !ok { return nil, fmt.Errorf("certificate not found: %s", id) } return cert, nil } // GetCertificateByEnrollment retrieves a certificate by enrollment ID. func (s *AcademyMemStore) GetCertificateByEnrollment(enrollmentID string) (*AcademyCertificateRow, error) { s.mu.RLock() defer s.mu.RUnlock() for _, cert := range s.certificates { if cert.EnrollmentID == enrollmentID { return cert, nil } } return nil, fmt.Errorf("certificate not found for enrollment: %s", enrollmentID) } // CreateCertificate inserts a new certificate with auto-generated ID. func (s *AcademyMemStore) CreateCertificate(row *AcademyCertificateRow) *AcademyCertificateRow { s.mu.Lock() defer s.mu.Unlock() row.ID = generateID() if row.IssuedAt.IsZero() { row.IssuedAt = time.Now() } s.certificates[row.ID] = row return row } // --------------------------------------------------------------------------- // Lesson Progress // --------------------------------------------------------------------------- // ListLessonProgress returns all progress entries for an enrollment. func (s *AcademyMemStore) ListLessonProgress(enrollmentID string) []*AcademyLessonProgressRow { s.mu.RLock() defer s.mu.RUnlock() var result []*AcademyLessonProgressRow for _, p := range s.lessonProgress { if p.EnrollmentID == enrollmentID { result = append(result, p) } } return result } // UpsertLessonProgress inserts or updates a lesson progress entry. // Matching is done by EnrollmentID + LessonID composite key. func (s *AcademyMemStore) UpsertLessonProgress(row *AcademyLessonProgressRow) *AcademyLessonProgressRow { s.mu.Lock() defer s.mu.Unlock() // Look for existing entry with same enrollment_id + lesson_id for _, p := range s.lessonProgress { if p.EnrollmentID == row.EnrollmentID && p.LessonID == row.LessonID { p.Completed = row.Completed p.QuizScore = row.QuizScore p.CompletedAt = row.CompletedAt return p } } // Insert new entry row.ID = generateID() s.lessonProgress[row.ID] = row return row } // --------------------------------------------------------------------------- // Statistics // --------------------------------------------------------------------------- // GetStatistics computes aggregate statistics for a tenant. func (s *AcademyMemStore) GetStatistics(tenantID string) *AcademyStatisticsRow { s.mu.RLock() defer s.mu.RUnlock() stats := &AcademyStatisticsRow{ ByCategory: make(map[string]int), ByStatus: make(map[string]int), } // Count courses by category for _, c := range s.courses { if c.TenantID != tenantID { continue } stats.TotalCourses++ if c.Category != "" { stats.ByCategory[c.Category]++ } } // Count enrollments and compute completion rate var completedCount int now := time.Now() for _, e := range s.enrollments { if e.TenantID != tenantID { continue } stats.TotalEnrollments++ stats.ByStatus[e.Status]++ if e.Status == "completed" { completedCount++ } // Overdue: not completed and past deadline if e.Status != "completed" && !e.Deadline.IsZero() && now.After(e.Deadline) { stats.OverdueCount++ } } if stats.TotalEnrollments > 0 { stats.CompletionRate = float64(completedCount) / float64(stats.TotalEnrollments) * 100.0 } return stats }