Yimaru-BackEnd/internal/services/lmsprogress/admin_recent_activity.go
Yared Yemane a80db8afd9 Add admin recent-activity timeline for learner profile UIs.
Expose GET /api/v1/admin/users/:user_id/recent-activity (progress.get_any_user) merging account creation and LMS completion milestones, with optional practice rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 01:13:21 -07:00

228 lines
7.0 KiB
Go

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
}