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>
This commit is contained in:
Yared Yemane 2026-05-18 00:58:38 -07:00
parent 062b1f6151
commit 52effaa321
10 changed files with 1063 additions and 0 deletions

View File

@ -0,0 +1,202 @@
-- Aggregated LMS learning activity for admin: completed lessons and completed practices
-- (rollup tables + lesson/practice completion are the persisted signals in this schema).
-- name: ListUserLMSFlatLearningActivityByUser :many
SELECT
x.activity_kind,
x.program_id,
x.program_name,
x.program_sort_order,
x.program_completed_at,
x.course_id,
x.course_name,
x.course_sort_order,
x.course_completed_at,
COALESCE(x.module_id, 0)::BIGINT AS module_id,
COALESCE(x.module_name, '')::TEXT AS module_name,
COALESCE(x.module_sort_order, 0)::INT AS module_sort_order,
x.module_completed_at,
COALESCE(x.lesson_id, 0)::BIGINT AS lesson_id,
COALESCE(x.lesson_title, '')::TEXT AS lesson_title,
COALESCE(x.lesson_sort_order, 0)::INT AS lesson_sort_order,
x.lesson_completed_at,
COALESCE(x.lms_practice_id, 0)::BIGINT AS lms_practice_id,
COALESCE(x.practice_title, '')::TEXT AS practice_title,
x.activity_at
FROM (
SELECT
'lesson'::TEXT AS activity_kind,
p.id AS program_id,
p.name AS program_name,
p.sort_order AS program_sort_order,
prf.completed_at AS program_completed_at,
c.id AS course_id,
c.name AS course_name,
c.sort_order AS course_sort_order,
crf.completed_at AS course_completed_at,
m.id AS module_id,
m.name AS module_name,
m.sort_order AS module_sort_order,
mrf.completed_at AS module_completed_at,
l.id AS lesson_id,
l.title AS lesson_title,
l.sort_order AS lesson_sort_order,
ulp.completed_at AS lesson_completed_at,
NULL::BIGINT AS lms_practice_id,
NULL::VARCHAR AS practice_title,
ulp.completed_at AS activity_at
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
AND c.program_id = m.program_id
INNER JOIN programs p ON p.id = c.program_id
LEFT JOIN lms_user_program_progress prf ON prf.user_id = ulp.user_id
AND prf.program_id = p.id
LEFT JOIN lms_user_course_progress crf ON crf.user_id = ulp.user_id
AND crf.course_id = c.id
LEFT JOIN lms_user_module_progress mrf ON mrf.user_id = ulp.user_id
AND mrf.module_id = m.id
WHERE
ulp.user_id = $1
UNION ALL
SELECT
'practice'::TEXT,
p.id,
p.name,
p.sort_order,
prf.completed_at,
c.id,
c.name,
c.sort_order,
crf.completed_at,
m.id,
m.name,
m.sort_order,
mrf.completed_at,
l.id,
l.title,
l.sort_order,
lucomp.completed_at,
lp.id,
lp.title,
upp.completed_at
FROM
user_practice_progress upp
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
AND lp.lesson_id IS NOT NULL
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
INNER JOIN lessons l ON l.id = lp.lesson_id
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
AND c.program_id = m.program_id
INNER JOIN programs p ON p.id = c.program_id
LEFT JOIN lms_user_program_progress prf ON prf.user_id = upp.user_id
AND prf.program_id = p.id
LEFT JOIN lms_user_course_progress crf ON crf.user_id = upp.user_id
AND crf.course_id = c.id
LEFT JOIN lms_user_module_progress mrf ON mrf.user_id = upp.user_id
AND mrf.module_id = m.id
LEFT JOIN lms_user_lesson_progress lucomp ON lucomp.user_id = upp.user_id
AND lucomp.lesson_id = l.id
WHERE
upp.user_id = $1
AND upp.completed_at IS NOT NULL
UNION ALL
SELECT
'practice'::TEXT,
p.id,
p.name,
p.sort_order,
prf.completed_at,
c.id,
c.name,
c.sort_order,
crf.completed_at,
m.id,
m.name,
m.sort_order,
mrf.completed_at,
NULL::BIGINT,
NULL::VARCHAR,
NULL::INT,
NULL::TIMESTAMPTZ,
lp.id,
lp.title,
upp.completed_at
FROM
user_practice_progress upp
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
AND lp.module_id IS NOT NULL
AND lp.lesson_id IS NULL
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
INNER JOIN modules m ON m.id = lp.module_id
INNER JOIN courses c ON c.id = m.course_id
AND c.program_id = m.program_id
INNER JOIN programs p ON p.id = c.program_id
LEFT JOIN lms_user_program_progress prf ON prf.user_id = upp.user_id
AND prf.program_id = p.id
LEFT JOIN lms_user_course_progress crf ON crf.user_id = upp.user_id
AND crf.course_id = c.id
LEFT JOIN lms_user_module_progress mrf ON mrf.user_id = upp.user_id
AND mrf.module_id = m.id
WHERE
upp.user_id = $1
AND upp.completed_at IS NOT NULL
UNION ALL
SELECT
'practice'::TEXT,
p.id,
p.name,
p.sort_order,
prf.completed_at,
c.id,
c.name,
c.sort_order,
crf.completed_at,
NULL::BIGINT,
NULL::VARCHAR,
NULL::INT,
NULL::TIMESTAMPTZ,
NULL::BIGINT,
NULL::VARCHAR,
NULL::INT,
NULL::TIMESTAMPTZ,
lp.id,
lp.title,
upp.completed_at
FROM
user_practice_progress upp
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
AND lp.course_id IS NOT NULL
AND lp.module_id IS NULL
AND lp.lesson_id IS NULL
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN programs p ON p.id = c.program_id
LEFT JOIN lms_user_program_progress prf ON prf.user_id = upp.user_id
AND prf.program_id = p.id
LEFT JOIN lms_user_course_progress crf ON crf.user_id = upp.user_id
AND crf.course_id = c.id
WHERE
upp.user_id = $1
AND upp.completed_at IS NOT NULL
) AS x
ORDER BY
x.program_sort_order,
x.program_id,
x.course_sort_order,
x.course_id,
x.module_sort_order NULLS LAST,
x.module_id NULLS LAST,
x.lesson_sort_order NULLS LAST,
x.lesson_id NULLS LAST,
x.activity_kind,
x.activity_at;

View File

@ -602,6 +602,52 @@ const docTemplate = `{
} }
} }
}, },
"/api/v1/admin/users/{user_id}/lms-learning-activity": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns programs, courses, modules, and lessons with completion details and completed practices. Only persisted completion signals are included (completed lessons, completed published practices, and rollup completion timestamps—not partial or in-progress attempts).",
"produces": [
"application/json"
],
"tags": [
"lms"
],
"summary": "Get a user's nested LMS learning activity (admin)",
"parameters": [
{
"type": "integer",
"description": "Target user ID",
"name": "user_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/admin/{id}": { "/api/v1/admin/{id}": {
"get": { "get": {
"description": "Get a single admin by id", "description": "Get a single admin by id",

View File

@ -594,6 +594,52 @@
} }
} }
}, },
"/api/v1/admin/users/{user_id}/lms-learning-activity": {
"get": {
"security": [
{
"Bearer": []
}
],
"description": "Returns programs, courses, modules, and lessons with completion details and completed practices. Only persisted completion signals are included (completed lessons, completed published practices, and rollup completion timestamps—not partial or in-progress attempts).",
"produces": [
"application/json"
],
"tags": [
"lms"
],
"summary": "Get a user's nested LMS learning activity (admin)",
"parameters": [
{
"type": "integer",
"description": "Target user ID",
"name": "user_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/api/v1/admin/{id}": { "/api/v1/admin/{id}": {
"get": { "get": {
"description": "Get a single admin by id", "description": "Get a single admin by id",

View File

@ -2868,6 +2868,38 @@ paths:
summary: Update FAQ summary: Update FAQ
tags: tags:
- faqs - faqs
/api/v1/admin/users/{user_id}/lms-learning-activity:
get:
description: Returns programs, courses, modules, and lessons with completion
details and completed practices. Only persisted completion signals are included
(completed lessons, completed published practices, and rollup completion timestamps—not
partial or in-progress attempts).
parameters:
- description: Target user ID
in: path
name: user_id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/domain.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
security:
- Bearer: []
summary: Get a user's nested LMS learning activity (admin)
tags:
- lms
/api/v1/admin/users/deletion-requests: /api/v1/admin/users/deletion-requests:
get: get:
consumes: consumes:

View File

@ -0,0 +1,280 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_admin_activity.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const ListUserLMSFlatLearningActivityByUser = `-- name: ListUserLMSFlatLearningActivityByUser :many
SELECT
x.activity_kind,
x.program_id,
x.program_name,
x.program_sort_order,
x.program_completed_at,
x.course_id,
x.course_name,
x.course_sort_order,
x.course_completed_at,
COALESCE(x.module_id, 0)::BIGINT AS module_id,
COALESCE(x.module_name, '')::TEXT AS module_name,
COALESCE(x.module_sort_order, 0)::INT AS module_sort_order,
x.module_completed_at,
COALESCE(x.lesson_id, 0)::BIGINT AS lesson_id,
COALESCE(x.lesson_title, '')::TEXT AS lesson_title,
COALESCE(x.lesson_sort_order, 0)::INT AS lesson_sort_order,
x.lesson_completed_at,
COALESCE(x.lms_practice_id, 0)::BIGINT AS lms_practice_id,
COALESCE(x.practice_title, '')::TEXT AS practice_title,
x.activity_at
FROM (
SELECT
'lesson'::TEXT AS activity_kind,
p.id AS program_id,
p.name AS program_name,
p.sort_order AS program_sort_order,
prf.completed_at AS program_completed_at,
c.id AS course_id,
c.name AS course_name,
c.sort_order AS course_sort_order,
crf.completed_at AS course_completed_at,
m.id AS module_id,
m.name AS module_name,
m.sort_order AS module_sort_order,
mrf.completed_at AS module_completed_at,
l.id AS lesson_id,
l.title AS lesson_title,
l.sort_order AS lesson_sort_order,
ulp.completed_at AS lesson_completed_at,
NULL::BIGINT AS lms_practice_id,
NULL::VARCHAR AS practice_title,
ulp.completed_at AS activity_at
FROM
lms_user_lesson_progress ulp
INNER JOIN lessons l ON l.id = ulp.lesson_id
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
AND c.program_id = m.program_id
INNER JOIN programs p ON p.id = c.program_id
LEFT JOIN lms_user_program_progress prf ON prf.user_id = ulp.user_id
AND prf.program_id = p.id
LEFT JOIN lms_user_course_progress crf ON crf.user_id = ulp.user_id
AND crf.course_id = c.id
LEFT JOIN lms_user_module_progress mrf ON mrf.user_id = ulp.user_id
AND mrf.module_id = m.id
WHERE
ulp.user_id = $1
UNION ALL
SELECT
'practice'::TEXT,
p.id,
p.name,
p.sort_order,
prf.completed_at,
c.id,
c.name,
c.sort_order,
crf.completed_at,
m.id,
m.name,
m.sort_order,
mrf.completed_at,
l.id,
l.title,
l.sort_order,
lucomp.completed_at,
lp.id,
lp.title,
upp.completed_at
FROM
user_practice_progress upp
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
AND lp.lesson_id IS NOT NULL
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
INNER JOIN lessons l ON l.id = lp.lesson_id
INNER JOIN modules m ON m.id = l.module_id
INNER JOIN courses c ON c.id = m.course_id
AND c.program_id = m.program_id
INNER JOIN programs p ON p.id = c.program_id
LEFT JOIN lms_user_program_progress prf ON prf.user_id = upp.user_id
AND prf.program_id = p.id
LEFT JOIN lms_user_course_progress crf ON crf.user_id = upp.user_id
AND crf.course_id = c.id
LEFT JOIN lms_user_module_progress mrf ON mrf.user_id = upp.user_id
AND mrf.module_id = m.id
LEFT JOIN lms_user_lesson_progress lucomp ON lucomp.user_id = upp.user_id
AND lucomp.lesson_id = l.id
WHERE
upp.user_id = $1
AND upp.completed_at IS NOT NULL
UNION ALL
SELECT
'practice'::TEXT,
p.id,
p.name,
p.sort_order,
prf.completed_at,
c.id,
c.name,
c.sort_order,
crf.completed_at,
m.id,
m.name,
m.sort_order,
mrf.completed_at,
NULL::BIGINT,
NULL::VARCHAR,
NULL::INT,
NULL::TIMESTAMPTZ,
lp.id,
lp.title,
upp.completed_at
FROM
user_practice_progress upp
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
AND lp.module_id IS NOT NULL
AND lp.lesson_id IS NULL
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
INNER JOIN modules m ON m.id = lp.module_id
INNER JOIN courses c ON c.id = m.course_id
AND c.program_id = m.program_id
INNER JOIN programs p ON p.id = c.program_id
LEFT JOIN lms_user_program_progress prf ON prf.user_id = upp.user_id
AND prf.program_id = p.id
LEFT JOIN lms_user_course_progress crf ON crf.user_id = upp.user_id
AND crf.course_id = c.id
LEFT JOIN lms_user_module_progress mrf ON mrf.user_id = upp.user_id
AND mrf.module_id = m.id
WHERE
upp.user_id = $1
AND upp.completed_at IS NOT NULL
UNION ALL
SELECT
'practice'::TEXT,
p.id,
p.name,
p.sort_order,
prf.completed_at,
c.id,
c.name,
c.sort_order,
crf.completed_at,
NULL::BIGINT,
NULL::VARCHAR,
NULL::INT,
NULL::TIMESTAMPTZ,
NULL::BIGINT,
NULL::VARCHAR,
NULL::INT,
NULL::TIMESTAMPTZ,
lp.id,
lp.title,
upp.completed_at
FROM
user_practice_progress upp
INNER JOIN lms_practices lp ON lp.question_set_id = upp.question_set_id
AND lp.course_id IS NOT NULL
AND lp.module_id IS NULL
AND lp.lesson_id IS NULL
INNER JOIN question_sets qs ON qs.id = upp.question_set_id
AND qs.set_type = 'PRACTICE'
AND qs.status = 'PUBLISHED'
INNER JOIN courses c ON c.id = lp.course_id
INNER JOIN programs p ON p.id = c.program_id
LEFT JOIN lms_user_program_progress prf ON prf.user_id = upp.user_id
AND prf.program_id = p.id
LEFT JOIN lms_user_course_progress crf ON crf.user_id = upp.user_id
AND crf.course_id = c.id
WHERE
upp.user_id = $1
AND upp.completed_at IS NOT NULL
) AS x
ORDER BY
x.program_sort_order,
x.program_id,
x.course_sort_order,
x.course_id,
x.module_sort_order NULLS LAST,
x.module_id NULLS LAST,
x.lesson_sort_order NULLS LAST,
x.lesson_id NULLS LAST,
x.activity_kind,
x.activity_at
`
type ListUserLMSFlatLearningActivityByUserRow struct {
ActivityKind string `json:"activity_kind"`
ProgramID int64 `json:"program_id"`
ProgramName string `json:"program_name"`
ProgramSortOrder int32 `json:"program_sort_order"`
ProgramCompletedAt pgtype.Timestamptz `json:"program_completed_at"`
CourseID int64 `json:"course_id"`
CourseName string `json:"course_name"`
CourseSortOrder int32 `json:"course_sort_order"`
CourseCompletedAt pgtype.Timestamptz `json:"course_completed_at"`
ModuleID int64 `json:"module_id"`
ModuleName string `json:"module_name"`
ModuleSortOrder int32 `json:"module_sort_order"`
ModuleCompletedAt pgtype.Timestamptz `json:"module_completed_at"`
LessonID int64 `json:"lesson_id"`
LessonTitle string `json:"lesson_title"`
LessonSortOrder int32 `json:"lesson_sort_order"`
LessonCompletedAt pgtype.Timestamptz `json:"lesson_completed_at"`
LmsPracticeID int64 `json:"lms_practice_id"`
PracticeTitle string `json:"practice_title"`
ActivityAt pgtype.Timestamptz `json:"activity_at"`
}
// Aggregated LMS learning activity for admin: completed lessons and completed practices
// (rollup tables + lesson/practice completion are the persisted signals in this schema).
func (q *Queries) ListUserLMSFlatLearningActivityByUser(ctx context.Context, userID int64) ([]ListUserLMSFlatLearningActivityByUserRow, error) {
rows, err := q.db.Query(ctx, ListUserLMSFlatLearningActivityByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListUserLMSFlatLearningActivityByUserRow
for rows.Next() {
var i ListUserLMSFlatLearningActivityByUserRow
if err := rows.Scan(
&i.ActivityKind,
&i.ProgramID,
&i.ProgramName,
&i.ProgramSortOrder,
&i.ProgramCompletedAt,
&i.CourseID,
&i.CourseName,
&i.CourseSortOrder,
&i.CourseCompletedAt,
&i.ModuleID,
&i.ModuleName,
&i.ModuleSortOrder,
&i.ModuleCompletedAt,
&i.LessonID,
&i.LessonTitle,
&i.LessonSortOrder,
&i.LessonCompletedAt,
&i.LmsPracticeID,
&i.PracticeTitle,
&i.ActivityAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -0,0 +1,56 @@
package domain
import "time"
// AdminLMSUserLearningActivityTree is a nested LMS view for admins: programs → courses → modules,
// plus lessons/practices where the learner has recorded completion (see API description for schema limits).
type AdminLMSUserLearningActivityTree struct {
UserID int64 `json:"user_id"`
Programs []AdminLMSProgramLearningEntry `json:"programs"`
}
// AdminLMSProgramLearningEntry aggregates activity under one program (sequential LMS track).
type AdminLMSProgramLearningEntry struct {
ID int64 `json:"id"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
RollupFullyCompletedAt *time.Time `json:"rollup_completed_at,omitempty"`
Courses []AdminLMSCourseLearningEntry `json:"courses"`
}
// AdminLMSCourseLearningEntry aggregates activity under one course inside a program.
type AdminLMSCourseLearningEntry struct {
ID int64 `json:"id"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
RollupFullyCompletedAt *time.Time `json:"rollup_completed_at,omitempty"`
Modules []AdminLMSModuleLearningEntry `json:"modules"`
CourseLevelPractices []AdminLMSPracticeLearningEntry `json:"course_level_practices,omitempty"`
}
// AdminLMSModuleLearningEntry aggregates activity under one module inside a course.
type AdminLMSModuleLearningEntry struct {
ID int64 `json:"id"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
RollupFullyCompletedAt *time.Time `json:"rollup_completed_at,omitempty"`
Lessons []AdminLMSLessonLearningEntry `json:"lessons,omitempty"`
ModuleScopedPractices []AdminLMSPracticeLearningEntry `json:"module_practices,omitempty"`
}
// AdminLMSLessonLearningEntry is lesson-scoped LMS activity (lesson marked complete and/or lesson practices completed).
type AdminLMSLessonLearningEntry struct {
ID int64 `json:"id"`
Title string `json:"title"`
SortOrder int32 `json:"sort_order"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
LessonScopedPractices []AdminLMSPracticeLearningEntry `json:"lesson_practices,omitempty"`
}
// AdminLMSPracticeLearningEntry is an LMS practice completion (lesson-, module-, or course-scoped).
type AdminLMSPracticeLearningEntry struct {
LMSPracticeID int64 `json:"lms_practice_id"`
Title string `json:"title"`
Scope string `json:"scope"`
CompletedAt time.Time `json:"completed_at"`
}

View File

@ -3,6 +3,7 @@ package repository
import ( import (
"context" "context"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
) )
@ -31,3 +32,8 @@ func (s *Store) GetLMSUserProgressSnapshot(ctx context.Context, userID int64) (d
ProgramIDs: programs, ProgramIDs: programs,
}, nil }, nil
} }
// ListUserLMSFlatLearningActivity returns flattened LMS activity rows for admin reporting (lesson + practice completions).
func (s *Store) ListUserLMSFlatLearningActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLMSFlatLearningActivityByUserRow, error) {
return s.queries.ListUserLMSFlatLearningActivityByUser(ctx, userID)
}

View File

@ -0,0 +1,351 @@
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
}

View File

@ -1,6 +1,8 @@
package handlers package handlers
import ( import (
"strconv"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@ -30,3 +32,44 @@ func (h *Handler) GetMyLMSProgress(c *fiber.Ctx) error {
StatusCode: fiber.StatusOK, StatusCode: fiber.StatusOK,
}) })
} }
// AdminGetUserLMSLearningActivity godoc
// @Summary Get a user's nested LMS learning activity (admin)
// @Description Returns programs, courses, modules, and lessons with completion details and completed practices. Only persisted completion signals are included (completed lessons, completed published practices, and rollup completion timestamps—not partial or in-progress attempts).
// @Tags lms
// @Produce json
// @Security Bearer
// @Param user_id path int true "Target user ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/users/{user_id}/lms-learning-activity [get]
func (h *Handler) AdminGetUserLMSLearningActivity(c *fiber.Ctx) error {
targetIDStr := c.Params("user_id")
targetID, err := strconv.ParseInt(targetIDStr, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid user ID",
Error: err.Error(),
})
}
if targetID <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid user ID",
Error: "user ID must be a positive integer",
})
}
tree, err := h.lmsProgressSvc.AdminUserLearningActivityTree(c.Context(), targetID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to load LMS learning activity",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "LMS learning activity retrieved successfully",
Data: tree,
Success: true,
StatusCode: fiber.StatusOK,
})
}

View File

@ -277,6 +277,7 @@ func (a *App) initAppRoutes() {
groupV1.Get("/user/:user_id/is-profile-completed", a.authMiddleware, a.RequirePermission("users.profile_completed"), h.CheckProfileCompleted) groupV1.Get("/user/:user_id/is-profile-completed", a.authMiddleware, a.RequirePermission("users.profile_completed"), h.CheckProfileCompleted)
groupV1.Get("/users", a.authMiddleware, a.RequirePermission("users.list"), h.GetAllUsers) groupV1.Get("/users", a.authMiddleware, a.RequirePermission("users.list"), h.GetAllUsers)
groupV1.Get("/admin/users/deletion-requests", a.authMiddleware, a.RequirePermission("users.deletion_requests.list"), h.ListAccountDeletionRequests) groupV1.Get("/admin/users/deletion-requests", a.authMiddleware, a.RequirePermission("users.deletion_requests.list"), h.ListAccountDeletionRequests)
groupV1.Get("/admin/users/:user_id/lms-learning-activity", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.AdminGetUserLMSLearningActivity)
groupV1.Get("/users/summary", a.authMiddleware, a.RequirePermission("users.summary"), h.GetUserSummary) groupV1.Get("/users/summary", a.authMiddleware, a.RequirePermission("users.summary"), h.GetUserSummary)
groupV1.Put("/user", a.authMiddleware, a.RequirePermission("users.update_self"), h.UpdateUser) groupV1.Put("/user", a.authMiddleware, a.RequirePermission("users.update_self"), h.UpdateUser)
groupV1.Patch("/user/status", a.authMiddleware, a.RequirePermission("users.update_status"), h.UpdateUserStatus) groupV1.Patch("/user/status", a.authMiddleware, a.RequirePermission("users.update_status"), h.UpdateUserStatus)