package lmsprogress import ( "context" "errors" "fmt" "sort" "github.com/jackc/pgx/v5" "Yimaru-Backend/internal/domain" ) const recentActivityJoinedHeadline = "Joined Yimaru" func headlineLessonUnit(moduleSortOrder int32, lessonTitle string) string { if moduleSortOrder > 0 && lessonTitle != "" { return fmt.Sprintf("Completed unit %d: %s", moduleSortOrder, lessonTitle) } if lessonTitle != "" { return "Completed lesson: " + lessonTitle } return "Completed lesson" } func headlineModuleUnit(moduleSortOrder int32, moduleName string) string { if moduleSortOrder > 0 && moduleName != "" { return fmt.Sprintf("Completed unit %d: %s", moduleSortOrder, moduleName) } if moduleName != "" { return "Completed module: " + moduleName } return "Completed module" } func kindRank(kind string) int { switch kind { case domain.UserRecentActivityJoined: return 10 case domain.UserRecentActivityProgramCompleted: return 8 case domain.UserRecentActivityCourseCompleted: return 6 case domain.UserRecentActivityModuleCompleted: return 4 case domain.UserRecentActivityLessonCompleted: return 2 case domain.UserRecentActivityPracticeCompleted: return 0 default: return 0 } } // AdminUserRecentActivity returns a reverse-chronological feed for admin profile / activity UIs. // Only completion milestones and account creation are included; "started learning path" is not stored and is not synthesized. func (s *Service) AdminUserRecentActivity(ctx context.Context, userID int64, limit int, includePractices bool) (domain.UserRecentActivityFeed, error) { if limit <= 0 { limit = 40 } if limit > 120 { limit = 120 } createdAt, err := s.store.GetUserCreatedAt(ctx, userID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.UserRecentActivityFeed{}, domain.ErrUserNotFound } return domain.UserRecentActivityFeed{}, err } var items []domain.UserRecentActivityItem items = append(items, domain.UserRecentActivityItem{ ID: fmt.Sprintf("joined:%d:%d", userID, createdAt.UnixNano()), Kind: domain.UserRecentActivityJoined, OccurredAt: createdAt, Headline: recentActivityJoinedHeadline, }) lessons, err := s.store.ListUserLessonCompletionsRecentActivity(ctx, userID) if err != nil { return domain.UserRecentActivityFeed{}, err } for _, row := range lessons { at, ok := pgTimestamptzTime(row.OccurredAt) if !ok { continue } items = append(items, domain.UserRecentActivityItem{ ID: fmt.Sprintf("lesson:%d:%d", row.LessonID, at.UnixNano()), Kind: domain.UserRecentActivityLessonCompleted, OccurredAt: at, Headline: headlineLessonUnit(row.ModuleSortOrder, row.LessonTitle), Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName}, Course: &domain.RecentActivityCourseRef{ID: row.CourseID, Name: row.CourseName}, Module: &domain.RecentActivityModuleRef{ID: row.ModuleID, Name: row.ModuleName, SortOrder: row.ModuleSortOrder}, Lesson: &domain.RecentActivityLessonRef{ID: row.LessonID, Title: row.LessonTitle}, }) } mods, err := s.store.ListUserModuleCompletionsRecentActivity(ctx, userID) if err != nil { return domain.UserRecentActivityFeed{}, err } for _, row := range mods { at, ok := pgTimestamptzTime(row.OccurredAt) if !ok { continue } items = append(items, domain.UserRecentActivityItem{ ID: fmt.Sprintf("module:%d:%d", row.ModuleID, at.UnixNano()), Kind: domain.UserRecentActivityModuleCompleted, OccurredAt: at, Headline: headlineModuleUnit(row.ModuleSortOrder, row.ModuleName), Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName}, Course: &domain.RecentActivityCourseRef{ID: row.CourseID, Name: row.CourseName}, Module: &domain.RecentActivityModuleRef{ID: row.ModuleID, Name: row.ModuleName, SortOrder: row.ModuleSortOrder}, }) } courses, err := s.store.ListUserCourseCompletionsRecentActivity(ctx, userID) if err != nil { return domain.UserRecentActivityFeed{}, err } for _, row := range courses { at, ok := pgTimestamptzTime(row.OccurredAt) if !ok { continue } headline := "Completed course" if row.CourseName != "" { headline = "Completed course: " + row.CourseName } items = append(items, domain.UserRecentActivityItem{ ID: fmt.Sprintf("course:%d:%d", row.CourseID, at.UnixNano()), Kind: domain.UserRecentActivityCourseCompleted, OccurredAt: at, Headline: headline, Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName}, Course: &domain.RecentActivityCourseRef{ID: row.CourseID, Name: row.CourseName}, }) } progs, err := s.store.ListUserProgramCompletionsRecentActivity(ctx, userID) if err != nil { return domain.UserRecentActivityFeed{}, err } for _, row := range progs { at, ok := pgTimestamptzTime(row.OccurredAt) if !ok { continue } headline := "Completed learning path" if row.ProgramName != "" { headline = "Completed learning path: " + row.ProgramName } items = append(items, domain.UserRecentActivityItem{ ID: fmt.Sprintf("program:%d:%d", row.ProgramID, at.UnixNano()), Kind: domain.UserRecentActivityProgramCompleted, OccurredAt: at, Headline: headline, Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName}, }) } if includePractices { practices, err := s.store.ListUserPracticeCompletionsRecentActivity(ctx, userID) if err != nil { return domain.UserRecentActivityFeed{}, err } for _, row := range practices { at, ok := pgTimestamptzTime(row.OccurredAt) if !ok { continue } headline := "Completed practice" if row.PracticeTitle != "" { headline = "Completed practice: " + row.PracticeTitle } item := domain.UserRecentActivityItem{ ID: fmt.Sprintf("practice:%d:%d", row.LmsPracticeID, at.UnixNano()), Kind: domain.UserRecentActivityPracticeCompleted, OccurredAt: at, Headline: headline, Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName}, Course: &domain.RecentActivityCourseRef{ID: row.CourseID, Name: row.CourseName}, Practice: &domain.RecentActivityPracticeRef{ LMSPracticeID: row.LmsPracticeID, Title: row.PracticeTitle, Scope: row.Scope, }, } if row.ModuleID != 0 { item.Module = &domain.RecentActivityModuleRef{ ID: row.ModuleID, Name: row.ModuleName, SortOrder: row.ModuleSortOrder, } } if row.LessonID != 0 { item.Lesson = &domain.RecentActivityLessonRef{ID: row.LessonID, Title: row.LessonTitle} } items = append(items, item) } } sort.SliceStable(items, func(i, j int) bool { ti, tj := items[i].OccurredAt, items[j].OccurredAt if !ti.Equal(tj) { return ti.After(tj) } ri, rj := kindRank(items[i].Kind), kindRank(items[j].Kind) if ri != rj { return ri > rj } return items[i].ID < items[j].ID }) if len(items) > limit { items = items[:limit] } return domain.UserRecentActivityFeed{ UserID: userID, Items: items, }, nil }