package lmsprogress import ( "context" "sort" "time" dbgen "Yimaru-Backend/gen/db" "Yimaru-Backend/internal/domain" "github.com/jackc/pgx/v5/pgtype" ) const ( flatActivityLesson = "lesson" flatActivityPractice = "practice" practiceScopeLesson = "lesson" practiceScopeModule = "module" practiceScopeCourse = "course" ) // AdminUserLearningActivityTree returns nested program → course → module → lesson/practice completions for a learner. // The schema persists completion timestamps only (lesson completion, practice completion, rollup rows); partially started items do not appear. func (s *Service) AdminUserLearningActivityTree(ctx context.Context, userID int64) (domain.AdminLMSUserLearningActivityTree, error) { rows, err := s.store.ListUserLMSFlatLearningActivity(ctx, userID) if err != nil { return domain.AdminLMSUserLearningActivityTree{}, err } return buildAdminLearningActivityTree(userID, rows), nil } type lessonAccum struct { id int64 title string sortOrder int32 completedAt *time.Time practices []domain.AdminLMSPracticeLearningEntry practiceDed map[int64]struct{} } type moduleAccum struct { id int64 name string sortOrder int32 rollup *time.Time lessons map[int64]*lessonAccum lessonOrder []int64 modulePractices []domain.AdminLMSPracticeLearningEntry modulePracticeSeen map[int64]struct{} } type courseAccum struct { id int64 name string sortOrder int32 rollup *time.Time modules map[int64]*moduleAccum moduleOrder []int64 coursePractices []domain.AdminLMSPracticeLearningEntry coursePracticeSeen map[int64]struct{} } type programAccum struct { id int64 name string sortOrder int32 rollup *time.Time courses map[int64]*courseAccum courseOrder []int64 } type adminActivityTreeBuilder struct { programs map[int64]*programAccum programOrder []int64 } func newAdminActivityTreeBuilder() *adminActivityTreeBuilder { return &adminActivityTreeBuilder{ programs: make(map[int64]*programAccum), } } func (b *adminActivityTreeBuilder) ensureProgram(row dbgen.ListUserLMSFlatLearningActivityByUserRow) *programAccum { pa, ok := b.programs[row.ProgramID] if !ok { pa = &programAccum{ id: row.ProgramID, courses: make(map[int64]*courseAccum), } b.programs[row.ProgramID] = pa b.programOrder = append(b.programOrder, row.ProgramID) } pa.name = row.ProgramName pa.sortOrder = row.ProgramSortOrder if t := pgTimestamptzPtr(row.ProgramCompletedAt); t != nil { pa.rollup = t } return pa } func (pa *programAccum) ensureCourse(row dbgen.ListUserLMSFlatLearningActivityByUserRow) *courseAccum { ca, ok := pa.courses[row.CourseID] if !ok { ca = &courseAccum{ id: row.CourseID, modules: make(map[int64]*moduleAccum), coursePracticeSeen: make(map[int64]struct{}), } pa.courses[row.CourseID] = ca pa.courseOrder = append(pa.courseOrder, row.CourseID) } ca.name = row.CourseName ca.sortOrder = row.CourseSortOrder if t := pgTimestamptzPtr(row.CourseCompletedAt); t != nil { ca.rollup = t } return ca } func (ca *courseAccum) ensureModule(row dbgen.ListUserLMSFlatLearningActivityByUserRow) *moduleAccum { ma, ok := ca.modules[row.ModuleID] if !ok { ma = &moduleAccum{ id: row.ModuleID, lessons: make(map[int64]*lessonAccum), modulePracticeSeen: make(map[int64]struct{}), } ca.modules[row.ModuleID] = ma ca.moduleOrder = append(ca.moduleOrder, row.ModuleID) } ma.name = row.ModuleName ma.sortOrder = row.ModuleSortOrder if t := pgTimestamptzPtr(row.ModuleCompletedAt); t != nil { ma.rollup = t } return ma } func (ma *moduleAccum) ensureLesson(id int64, title string, sortOrder int32) *lessonAccum { la, ok := ma.lessons[id] if !ok { la = &lessonAccum{ id: id, title: title, sortOrder: sortOrder, practiceDed: make(map[int64]struct{}), } ma.lessons[id] = la ma.lessonOrder = append(ma.lessonOrder, id) } if title != "" { la.title = title } return la } func (la *lessonAccum) addPractice(p domain.AdminLMSPracticeLearningEntry) { if _, dup := la.practiceDed[p.LMSPracticeID]; dup { return } la.practiceDed[p.LMSPracticeID] = struct{}{} la.practices = append(la.practices, p) } func (ma *moduleAccum) addModulePractice(p domain.AdminLMSPracticeLearningEntry) { if _, dup := ma.modulePracticeSeen[p.LMSPracticeID]; dup { return } ma.modulePracticeSeen[p.LMSPracticeID] = struct{}{} ma.modulePractices = append(ma.modulePractices, p) } func (ca *courseAccum) addCoursePractice(p domain.AdminLMSPracticeLearningEntry) { if _, dup := ca.coursePracticeSeen[p.LMSPracticeID]; dup { return } ca.coursePracticeSeen[p.LMSPracticeID] = struct{}{} ca.coursePractices = append(ca.coursePractices, p) } func (b *adminActivityTreeBuilder) ingest(row dbgen.ListUserLMSFlatLearningActivityByUserRow) { switch row.ActivityKind { case flatActivityLesson: if row.LessonID == 0 { return } p := b.ensureProgram(row) c := p.ensureCourse(row) m := c.ensureModule(row) l := m.ensureLesson(row.LessonID, row.LessonTitle, row.LessonSortOrder) if t := pgTimestamptzPtr(row.LessonCompletedAt); t != nil { l.completedAt = t } case flatActivityPractice: if row.LmsPracticeID == 0 { return } at, ok := pgTimestamptzTime(row.ActivityAt) if !ok { return } pr := domain.AdminLMSPracticeLearningEntry{ LMSPracticeID: row.LmsPracticeID, Title: row.PracticeTitle, CompletedAt: at, } p := b.ensureProgram(row) c := p.ensureCourse(row) switch { case row.LessonID != 0: pr.Scope = practiceScopeLesson m := c.ensureModule(row) l := m.ensureLesson(row.LessonID, row.LessonTitle, row.LessonSortOrder) l.addPractice(pr) case row.ModuleID != 0: pr.Scope = practiceScopeModule m := c.ensureModule(row) m.addModulePractice(pr) default: pr.Scope = practiceScopeCourse c.addCoursePractice(pr) } } } func sortPracticeSlice(ps []domain.AdminLMSPracticeLearningEntry) { sort.Slice(ps, func(i, j int) bool { if !ps[i].CompletedAt.Equal(ps[j].CompletedAt) { return ps[i].CompletedAt.Before(ps[j].CompletedAt) } return ps[i].LMSPracticeID < ps[j].LMSPracticeID }) } func buildAdminLearningActivityTree(userID int64, rows []dbgen.ListUserLMSFlatLearningActivityByUserRow) domain.AdminLMSUserLearningActivityTree { b := newAdminActivityTreeBuilder() for i := range rows { b.ingest(rows[i]) } sort.Slice(b.programOrder, func(i, j int) bool { a := b.programs[b.programOrder[i]] cc := b.programs[b.programOrder[j]] if a.sortOrder != cc.sortOrder { return a.sortOrder < cc.sortOrder } return a.id < cc.id }) outPrograms := make([]domain.AdminLMSProgramLearningEntry, 0, len(b.programOrder)) for _, pid := range b.programOrder { pa := b.programs[pid] sort.Slice(pa.courseOrder, func(i, j int) bool { a := pa.courses[pa.courseOrder[i]] c := pa.courses[pa.courseOrder[j]] if a.sortOrder != c.sortOrder { return a.sortOrder < c.sortOrder } return a.id < c.id }) courses := make([]domain.AdminLMSCourseLearningEntry, 0, len(pa.courseOrder)) for _, cid := range pa.courseOrder { ca := pa.courses[cid] sort.Slice(ca.moduleOrder, func(i, j int) bool { a := ca.modules[ca.moduleOrder[i]] c := ca.modules[ca.moduleOrder[j]] if a.sortOrder != c.sortOrder { return a.sortOrder < c.sortOrder } return a.id < c.id }) modules := make([]domain.AdminLMSModuleLearningEntry, 0, len(ca.moduleOrder)) for _, mid := range ca.moduleOrder { ma := ca.modules[mid] sort.Slice(ma.lessonOrder, func(i, j int) bool { a := ma.lessons[ma.lessonOrder[i]] c := ma.lessons[ma.lessonOrder[j]] if a.sortOrder != c.sortOrder { return a.sortOrder < c.sortOrder } return a.id < c.id }) lessons := make([]domain.AdminLMSLessonLearningEntry, 0, len(ma.lessonOrder)) for _, lid := range ma.lessonOrder { la := ma.lessons[lid] sortPracticeSlice(la.practices) entry := domain.AdminLMSLessonLearningEntry{ ID: la.id, Title: la.title, SortOrder: la.sortOrder, CompletedAt: la.completedAt, LessonScopedPractices: la.practices, } lessons = append(lessons, entry) } mod := domain.AdminLMSModuleLearningEntry{ ID: ma.id, Name: ma.name, SortOrder: ma.sortOrder, RollupFullyCompletedAt: ma.rollup, } if len(lessons) > 0 { mod.Lessons = lessons } if len(ma.modulePractices) > 0 { sortPracticeSlice(ma.modulePractices) mod.ModuleScopedPractices = ma.modulePractices } modules = append(modules, mod) } cr := domain.AdminLMSCourseLearningEntry{ ID: ca.id, Name: ca.name, SortOrder: ca.sortOrder, RollupFullyCompletedAt: ca.rollup, Modules: modules, } if len(ca.coursePractices) > 0 { sortPracticeSlice(ca.coursePractices) cr.CourseLevelPractices = ca.coursePractices } courses = append(courses, cr) } outPrograms = append(outPrograms, domain.AdminLMSProgramLearningEntry{ ID: pa.id, Name: pa.name, SortOrder: pa.sortOrder, RollupFullyCompletedAt: pa.rollup, Courses: courses, }) } return domain.AdminLMSUserLearningActivityTree{ UserID: userID, Programs: outPrograms, } } func pgTimestamptzPtr(t pgtype.Timestamptz) *time.Time { if !t.Valid { return nil } tt := t.Time return &tt } func pgTimestamptzTime(t pgtype.Timestamptz) (time.Time, bool) { if !t.Valid { return time.Time{}, false } return t.Time, true }