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>
352 lines
9.7 KiB
Go
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
|
|
}
|