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:
parent
062b1f6151
commit
52effaa321
202
db/query/lms_admin_activity.sql
Normal file
202
db/query/lms_admin_activity.sql
Normal 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;
|
||||||
46
docs/docs.go
46
docs/docs.go
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
280
gen/db/lms_admin_activity.sql.go
Normal file
280
gen/db/lms_admin_activity.sql.go
Normal 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
|
||||||
|
}
|
||||||
56
internal/domain/lms_admin_learning.go
Normal file
56
internal/domain/lms_admin_learning.go
Normal 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"`
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
351
internal/services/lmsprogress/admin_learning_activity.go
Normal file
351
internal/services/lmsprogress/admin_learning_activity.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user