Yimaru-BackEnd/internal/services/lmsprogress/admin_learning_activity.go
Yared Yemane 52effaa321 Add admin endpoint for nested user LMS completion activity.
Expose GET /api/v1/admin/users/:user_id/lms-learning-activity for progress.get_any_user so admins see program/course/module/lesson completions and practices from stored completion rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:58:49 -07:00

352 lines
9.7 KiB
Go

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
}