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>
228 lines
7.0 KiB
Go
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
|
|
}
|