Add admin recent-activity timeline for learner profile UIs.
Expose GET /api/v1/admin/users/:user_id/recent-activity (progress.get_any_user) merging account creation and LMS completion milestones, with optional practice rows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
52effaa321
commit
a80db8afd9
|
|
@ -141,6 +141,11 @@ RETURNING
|
|||
updated_at;
|
||||
|
||||
|
||||
-- name: GetUserCreatedAt :one
|
||||
SELECT created_at
|
||||
FROM users
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT *
|
||||
FROM users
|
||||
|
|
|
|||
173
db/query/user_recent_activity.sql
Normal file
173
db/query/user_recent_activity.sql
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
-- Recent activity feed: LMS completion milestones (chronological merge in application code).
|
||||
|
||||
-- name: ListUserLessonCompletionsRecentActivity :many
|
||||
SELECT
|
||||
ulp.completed_at AS occurred_at,
|
||||
l.id AS lesson_id,
|
||||
l.title AS lesson_title,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
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
|
||||
WHERE
|
||||
ulp.user_id = $1;
|
||||
|
||||
-- name: ListUserModuleCompletionsRecentActivity :many
|
||||
SELECT
|
||||
mrf.completed_at AS occurred_at,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_module_progress mrf
|
||||
INNER JOIN modules m ON m.id = mrf.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
|
||||
WHERE
|
||||
mrf.user_id = $1;
|
||||
|
||||
-- name: ListUserCourseCompletionsRecentActivity :many
|
||||
SELECT
|
||||
crf.completed_at AS occurred_at,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_course_progress crf
|
||||
INNER JOIN courses c ON c.id = crf.course_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
crf.user_id = $1;
|
||||
|
||||
-- name: ListUserProgramCompletionsRecentActivity :many
|
||||
SELECT
|
||||
prf.completed_at AS occurred_at,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_program_progress prf
|
||||
INNER JOIN programs p ON p.id = prf.program_id
|
||||
WHERE
|
||||
prf.user_id = $1;
|
||||
|
||||
-- name: ListUserPracticeCompletionsRecentActivity :many
|
||||
SELECT
|
||||
x.occurred_at,
|
||||
x.scope,
|
||||
x.lms_practice_id,
|
||||
x.practice_title,
|
||||
COALESCE(x.lesson_id, 0)::BIGINT AS lesson_id,
|
||||
COALESCE(x.lesson_title, '')::TEXT AS lesson_title,
|
||||
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.course_id,
|
||||
x.course_name,
|
||||
x.program_id,
|
||||
x.program_name
|
||||
FROM (
|
||||
SELECT
|
||||
upp.completed_at AS occurred_at,
|
||||
'lesson'::TEXT AS scope,
|
||||
lp.id AS lms_practice_id,
|
||||
lp.title AS practice_title,
|
||||
l.id AS lesson_id,
|
||||
l.title AS lesson_title,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
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
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
upp.completed_at,
|
||||
'module'::TEXT,
|
||||
lp.id,
|
||||
lp.title,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
m.id,
|
||||
m.name,
|
||||
m.sort_order,
|
||||
c.id,
|
||||
c.name,
|
||||
p.id,
|
||||
p.name
|
||||
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
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
upp.completed_at,
|
||||
'course'::TEXT,
|
||||
lp.id,
|
||||
lp.title,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::INT,
|
||||
c.id,
|
||||
c.name,
|
||||
p.id,
|
||||
p.name
|
||||
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
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
) AS x;
|
||||
64
docs/docs.go
64
docs/docs.go
|
|
@ -648,6 +648,70 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{user_id}/recent-activity": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Reverse-chronological feed for profile UI: account joined plus LMS completion milestones (lessons/modules/courses/programs). Optional practice completions via include_practices. Does not include \"started learning path\" unless you add persisted engagement events—the schema stores completions only.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"lms"
|
||||
],
|
||||
"summary": "Recent activity timeline for a user (admin)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Target user ID",
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Max items after merge (default 40, max 120)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include completed LMS practices (more verbose)",
|
||||
"name": "include_practices",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/{id}": {
|
||||
"get": {
|
||||
"description": "Get a single admin by id",
|
||||
|
|
|
|||
|
|
@ -640,6 +640,70 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{user_id}/recent-activity": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Reverse-chronological feed for profile UI: account joined plus LMS completion milestones (lessons/modules/courses/programs). Optional practice completions via include_practices. Does not include \"started learning path\" unless you add persisted engagement events—the schema stores completions only.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"lms"
|
||||
],
|
||||
"summary": "Recent activity timeline for a user (admin)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Target user ID",
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Max items after merge (default 40, max 120)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include completed LMS practices (more verbose)",
|
||||
"name": "include_practices",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/{id}": {
|
||||
"get": {
|
||||
"description": "Get a single admin by id",
|
||||
|
|
|
|||
|
|
@ -2900,6 +2900,50 @@ paths:
|
|||
summary: Get a user's nested LMS learning activity (admin)
|
||||
tags:
|
||||
- lms
|
||||
/api/v1/admin/users/{user_id}/recent-activity:
|
||||
get:
|
||||
description: 'Reverse-chronological feed for profile UI: account joined plus
|
||||
LMS completion milestones (lessons/modules/courses/programs). Optional practice
|
||||
completions via include_practices. Does not include "started learning path"
|
||||
unless you add persisted engagement events—the schema stores completions only.'
|
||||
parameters:
|
||||
- description: Target user ID
|
||||
in: path
|
||||
name: user_id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Max items after merge (default 40, max 120)
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- description: Include completed LMS practices (more verbose)
|
||||
in: query
|
||||
name: include_practices
|
||||
type: boolean
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/domain.Response'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Recent activity timeline for a user (admin)
|
||||
tags:
|
||||
- lms
|
||||
/api/v1/admin/users/deletion-requests:
|
||||
get:
|
||||
consumes:
|
||||
|
|
|
|||
|
|
@ -819,6 +819,19 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
|||
return i, err
|
||||
}
|
||||
|
||||
const GetUserCreatedAt = `-- name: GetUserCreatedAt :one
|
||||
SELECT created_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserCreatedAt(ctx context.Context, id int64) (pgtype.Timestamptz, error) {
|
||||
row := q.db.QueryRow(ctx, GetUserCreatedAt, id)
|
||||
var created_at pgtype.Timestamptz
|
||||
err := row.Scan(&created_at)
|
||||
return created_at, err
|
||||
}
|
||||
|
||||
const GetUserSummary = `-- name: GetUserSummary :one
|
||||
SELECT
|
||||
COUNT(*) AS total_users,
|
||||
|
|
|
|||
385
gen/db/user_recent_activity.sql.go
Normal file
385
gen/db/user_recent_activity.sql.go
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: user_recent_activity.sql
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const ListUserCourseCompletionsRecentActivity = `-- name: ListUserCourseCompletionsRecentActivity :many
|
||||
SELECT
|
||||
crf.completed_at AS occurred_at,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_course_progress crf
|
||||
INNER JOIN courses c ON c.id = crf.course_id
|
||||
INNER JOIN programs p ON p.id = c.program_id
|
||||
WHERE
|
||||
crf.user_id = $1
|
||||
`
|
||||
|
||||
type ListUserCourseCompletionsRecentActivityRow struct {
|
||||
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||
CourseID int64 `json:"course_id"`
|
||||
CourseName string `json:"course_name"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
ProgramName string `json:"program_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListUserCourseCompletionsRecentActivity(ctx context.Context, userID int64) ([]ListUserCourseCompletionsRecentActivityRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUserCourseCompletionsRecentActivity, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUserCourseCompletionsRecentActivityRow
|
||||
for rows.Next() {
|
||||
var i ListUserCourseCompletionsRecentActivityRow
|
||||
if err := rows.Scan(
|
||||
&i.OccurredAt,
|
||||
&i.CourseID,
|
||||
&i.CourseName,
|
||||
&i.ProgramID,
|
||||
&i.ProgramName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListUserLessonCompletionsRecentActivity = `-- name: ListUserLessonCompletionsRecentActivity :many
|
||||
|
||||
SELECT
|
||||
ulp.completed_at AS occurred_at,
|
||||
l.id AS lesson_id,
|
||||
l.title AS lesson_title,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
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
|
||||
WHERE
|
||||
ulp.user_id = $1
|
||||
`
|
||||
|
||||
type ListUserLessonCompletionsRecentActivityRow struct {
|
||||
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||
LessonID int64 `json:"lesson_id"`
|
||||
LessonTitle string `json:"lesson_title"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
ModuleName string `json:"module_name"`
|
||||
ModuleSortOrder int32 `json:"module_sort_order"`
|
||||
CourseID int64 `json:"course_id"`
|
||||
CourseName string `json:"course_name"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
ProgramName string `json:"program_name"`
|
||||
}
|
||||
|
||||
// Recent activity feed: LMS completion milestones (chronological merge in application code).
|
||||
func (q *Queries) ListUserLessonCompletionsRecentActivity(ctx context.Context, userID int64) ([]ListUserLessonCompletionsRecentActivityRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUserLessonCompletionsRecentActivity, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUserLessonCompletionsRecentActivityRow
|
||||
for rows.Next() {
|
||||
var i ListUserLessonCompletionsRecentActivityRow
|
||||
if err := rows.Scan(
|
||||
&i.OccurredAt,
|
||||
&i.LessonID,
|
||||
&i.LessonTitle,
|
||||
&i.ModuleID,
|
||||
&i.ModuleName,
|
||||
&i.ModuleSortOrder,
|
||||
&i.CourseID,
|
||||
&i.CourseName,
|
||||
&i.ProgramID,
|
||||
&i.ProgramName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListUserModuleCompletionsRecentActivity = `-- name: ListUserModuleCompletionsRecentActivity :many
|
||||
SELECT
|
||||
mrf.completed_at AS occurred_at,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_module_progress mrf
|
||||
INNER JOIN modules m ON m.id = mrf.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
|
||||
WHERE
|
||||
mrf.user_id = $1
|
||||
`
|
||||
|
||||
type ListUserModuleCompletionsRecentActivityRow struct {
|
||||
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
ModuleName string `json:"module_name"`
|
||||
ModuleSortOrder int32 `json:"module_sort_order"`
|
||||
CourseID int64 `json:"course_id"`
|
||||
CourseName string `json:"course_name"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
ProgramName string `json:"program_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListUserModuleCompletionsRecentActivity(ctx context.Context, userID int64) ([]ListUserModuleCompletionsRecentActivityRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUserModuleCompletionsRecentActivity, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUserModuleCompletionsRecentActivityRow
|
||||
for rows.Next() {
|
||||
var i ListUserModuleCompletionsRecentActivityRow
|
||||
if err := rows.Scan(
|
||||
&i.OccurredAt,
|
||||
&i.ModuleID,
|
||||
&i.ModuleName,
|
||||
&i.ModuleSortOrder,
|
||||
&i.CourseID,
|
||||
&i.CourseName,
|
||||
&i.ProgramID,
|
||||
&i.ProgramName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListUserPracticeCompletionsRecentActivity = `-- name: ListUserPracticeCompletionsRecentActivity :many
|
||||
SELECT
|
||||
x.occurred_at,
|
||||
x.scope,
|
||||
x.lms_practice_id,
|
||||
x.practice_title,
|
||||
COALESCE(x.lesson_id, 0)::BIGINT AS lesson_id,
|
||||
COALESCE(x.lesson_title, '')::TEXT AS lesson_title,
|
||||
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.course_id,
|
||||
x.course_name,
|
||||
x.program_id,
|
||||
x.program_name
|
||||
FROM (
|
||||
SELECT
|
||||
upp.completed_at AS occurred_at,
|
||||
'lesson'::TEXT AS scope,
|
||||
lp.id AS lms_practice_id,
|
||||
lp.title AS practice_title,
|
||||
l.id AS lesson_id,
|
||||
l.title AS lesson_title,
|
||||
m.id AS module_id,
|
||||
m.name AS module_name,
|
||||
m.sort_order AS module_sort_order,
|
||||
c.id AS course_id,
|
||||
c.name AS course_name,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
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
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
upp.completed_at,
|
||||
'module'::TEXT,
|
||||
lp.id,
|
||||
lp.title,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
m.id,
|
||||
m.name,
|
||||
m.sort_order,
|
||||
c.id,
|
||||
c.name,
|
||||
p.id,
|
||||
p.name
|
||||
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
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
upp.completed_at,
|
||||
'course'::TEXT,
|
||||
lp.id,
|
||||
lp.title,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::BIGINT,
|
||||
NULL::VARCHAR,
|
||||
NULL::INT,
|
||||
c.id,
|
||||
c.name,
|
||||
p.id,
|
||||
p.name
|
||||
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
|
||||
WHERE
|
||||
upp.user_id = $1
|
||||
AND upp.completed_at IS NOT NULL
|
||||
) AS x
|
||||
`
|
||||
|
||||
type ListUserPracticeCompletionsRecentActivityRow struct {
|
||||
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||
Scope string `json:"scope"`
|
||||
LmsPracticeID int64 `json:"lms_practice_id"`
|
||||
PracticeTitle string `json:"practice_title"`
|
||||
LessonID int64 `json:"lesson_id"`
|
||||
LessonTitle string `json:"lesson_title"`
|
||||
ModuleID int64 `json:"module_id"`
|
||||
ModuleName string `json:"module_name"`
|
||||
ModuleSortOrder int32 `json:"module_sort_order"`
|
||||
CourseID int64 `json:"course_id"`
|
||||
CourseName string `json:"course_name"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
ProgramName string `json:"program_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListUserPracticeCompletionsRecentActivity(ctx context.Context, userID int64) ([]ListUserPracticeCompletionsRecentActivityRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUserPracticeCompletionsRecentActivity, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUserPracticeCompletionsRecentActivityRow
|
||||
for rows.Next() {
|
||||
var i ListUserPracticeCompletionsRecentActivityRow
|
||||
if err := rows.Scan(
|
||||
&i.OccurredAt,
|
||||
&i.Scope,
|
||||
&i.LmsPracticeID,
|
||||
&i.PracticeTitle,
|
||||
&i.LessonID,
|
||||
&i.LessonTitle,
|
||||
&i.ModuleID,
|
||||
&i.ModuleName,
|
||||
&i.ModuleSortOrder,
|
||||
&i.CourseID,
|
||||
&i.CourseName,
|
||||
&i.ProgramID,
|
||||
&i.ProgramName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListUserProgramCompletionsRecentActivity = `-- name: ListUserProgramCompletionsRecentActivity :many
|
||||
SELECT
|
||||
prf.completed_at AS occurred_at,
|
||||
p.id AS program_id,
|
||||
p.name AS program_name
|
||||
FROM
|
||||
lms_user_program_progress prf
|
||||
INNER JOIN programs p ON p.id = prf.program_id
|
||||
WHERE
|
||||
prf.user_id = $1
|
||||
`
|
||||
|
||||
type ListUserProgramCompletionsRecentActivityRow struct {
|
||||
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
|
||||
ProgramID int64 `json:"program_id"`
|
||||
ProgramName string `json:"program_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListUserProgramCompletionsRecentActivity(ctx context.Context, userID int64) ([]ListUserProgramCompletionsRecentActivityRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListUserProgramCompletionsRecentActivity, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUserProgramCompletionsRecentActivityRow
|
||||
for rows.Next() {
|
||||
var i ListUserProgramCompletionsRecentActivityRow
|
||||
if err := rows.Scan(&i.OccurredAt, &i.ProgramID, &i.ProgramName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
67
internal/domain/user_recent_activity.go
Normal file
67
internal/domain/user_recent_activity.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// User-recent-activity feed kinds (for timeline UI: icon + copy).
|
||||
const (
|
||||
UserRecentActivityJoined = "joined"
|
||||
UserRecentActivityLessonCompleted = "lesson_completed"
|
||||
UserRecentActivityModuleCompleted = "module_completed"
|
||||
UserRecentActivityCourseCompleted = "course_completed"
|
||||
UserRecentActivityProgramCompleted = "program_completed"
|
||||
UserRecentActivityPracticeCompleted = "practice_completed"
|
||||
)
|
||||
|
||||
// UserRecentActivityFeed is a reverse-chronological list of notable user events (account + LMS completions).
|
||||
type UserRecentActivityFeed struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Items []UserRecentActivityItem `json:"items"`
|
||||
}
|
||||
|
||||
// UserRecentActivityItem is one row in a profile or admin "Recent activity" timeline.
|
||||
type UserRecentActivityItem struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
// Kind drives default icon treatment on the client (e.g. joined vs completion).
|
||||
Kind string `json:"kind"`
|
||||
|
||||
// OccurredAt is when the platform recorded the event (typically completion time).
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
|
||||
// Headline is optional server-rendered primary line (client may ignore and build from refs).
|
||||
Headline string `json:"headline"`
|
||||
|
||||
Program *RecentActivityProgramRef `json:"program,omitempty"`
|
||||
Course *RecentActivityCourseRef `json:"course,omitempty"`
|
||||
Module *RecentActivityModuleRef `json:"module,omitempty"`
|
||||
Lesson *RecentActivityLessonRef `json:"lesson,omitempty"`
|
||||
Practice *RecentActivityPracticeRef `json:"practice,omitempty"`
|
||||
}
|
||||
|
||||
type RecentActivityProgramRef struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type RecentActivityCourseRef struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type RecentActivityModuleRef struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type RecentActivityLessonRef struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
SortOrder int32 `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
||||
type RecentActivityPracticeRef struct {
|
||||
LMSPracticeID int64 `json:"lms_practice_id"`
|
||||
Title string `json:"title"`
|
||||
Scope string `json:"scope"` // lesson | module | course
|
||||
}
|
||||
|
|
@ -347,6 +347,18 @@ func (s *Store) GetUserByID(
|
|||
}, nil
|
||||
}
|
||||
|
||||
// GetUserCreatedAt returns account created_at (used for timeline "joined" events).
|
||||
func (s *Store) GetUserCreatedAt(ctx context.Context, userID int64) (time.Time, error) {
|
||||
ts, err := s.queries.GetUserCreatedAt(ctx, userID)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if !ts.Valid {
|
||||
return time.Time{}, pgx.ErrNoRows
|
||||
}
|
||||
return ts.Time, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByGoogleID(
|
||||
ctx context.Context,
|
||||
googleId string,
|
||||
|
|
|
|||
27
internal/repository/user_recent_activity.go
Normal file
27
internal/repository/user_recent_activity.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
dbgen "Yimaru-Backend/gen/db"
|
||||
)
|
||||
|
||||
func (s *Store) ListUserLessonCompletionsRecentActivity(ctx context.Context, userID int64) ([]dbgen.ListUserLessonCompletionsRecentActivityRow, error) {
|
||||
return s.queries.ListUserLessonCompletionsRecentActivity(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Store) ListUserModuleCompletionsRecentActivity(ctx context.Context, userID int64) ([]dbgen.ListUserModuleCompletionsRecentActivityRow, error) {
|
||||
return s.queries.ListUserModuleCompletionsRecentActivity(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Store) ListUserCourseCompletionsRecentActivity(ctx context.Context, userID int64) ([]dbgen.ListUserCourseCompletionsRecentActivityRow, error) {
|
||||
return s.queries.ListUserCourseCompletionsRecentActivity(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Store) ListUserProgramCompletionsRecentActivity(ctx context.Context, userID int64) ([]dbgen.ListUserProgramCompletionsRecentActivityRow, error) {
|
||||
return s.queries.ListUserProgramCompletionsRecentActivity(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Store) ListUserPracticeCompletionsRecentActivity(ctx context.Context, userID int64) ([]dbgen.ListUserPracticeCompletionsRecentActivityRow, error) {
|
||||
return s.queries.ListUserPracticeCompletionsRecentActivity(ctx, userID)
|
||||
}
|
||||
227
internal/services/lmsprogress/admin_recent_activity.go
Normal file
227
internal/services/lmsprogress/admin_recent_activity.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
package lmsprogress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"Yimaru-Backend/internal/domain"
|
||||
)
|
||||
|
||||
const recentActivityJoinedHeadline = "Joined Yimaru"
|
||||
|
||||
func headlineLessonUnit(moduleSortOrder int32, lessonTitle string) string {
|
||||
if moduleSortOrder > 0 && lessonTitle != "" {
|
||||
return fmt.Sprintf("Completed unit %d: %s", moduleSortOrder, lessonTitle)
|
||||
}
|
||||
if lessonTitle != "" {
|
||||
return "Completed lesson: " + lessonTitle
|
||||
}
|
||||
return "Completed lesson"
|
||||
}
|
||||
|
||||
func headlineModuleUnit(moduleSortOrder int32, moduleName string) string {
|
||||
if moduleSortOrder > 0 && moduleName != "" {
|
||||
return fmt.Sprintf("Completed unit %d: %s", moduleSortOrder, moduleName)
|
||||
}
|
||||
if moduleName != "" {
|
||||
return "Completed module: " + moduleName
|
||||
}
|
||||
return "Completed module"
|
||||
}
|
||||
|
||||
func kindRank(kind string) int {
|
||||
switch kind {
|
||||
case domain.UserRecentActivityJoined:
|
||||
return 10
|
||||
case domain.UserRecentActivityProgramCompleted:
|
||||
return 8
|
||||
case domain.UserRecentActivityCourseCompleted:
|
||||
return 6
|
||||
case domain.UserRecentActivityModuleCompleted:
|
||||
return 4
|
||||
case domain.UserRecentActivityLessonCompleted:
|
||||
return 2
|
||||
case domain.UserRecentActivityPracticeCompleted:
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// AdminUserRecentActivity returns a reverse-chronological feed for admin profile / activity UIs.
|
||||
// Only completion milestones and account creation are included; "started learning path" is not stored and is not synthesized.
|
||||
func (s *Service) AdminUserRecentActivity(ctx context.Context, userID int64, limit int, includePractices bool) (domain.UserRecentActivityFeed, error) {
|
||||
if limit <= 0 {
|
||||
limit = 40
|
||||
}
|
||||
if limit > 120 {
|
||||
limit = 120
|
||||
}
|
||||
|
||||
createdAt, err := s.store.GetUserCreatedAt(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.UserRecentActivityFeed{}, domain.ErrUserNotFound
|
||||
}
|
||||
return domain.UserRecentActivityFeed{}, err
|
||||
}
|
||||
|
||||
var items []domain.UserRecentActivityItem
|
||||
|
||||
items = append(items, domain.UserRecentActivityItem{
|
||||
ID: fmt.Sprintf("joined:%d:%d", userID, createdAt.UnixNano()),
|
||||
Kind: domain.UserRecentActivityJoined,
|
||||
OccurredAt: createdAt,
|
||||
Headline: recentActivityJoinedHeadline,
|
||||
})
|
||||
|
||||
lessons, err := s.store.ListUserLessonCompletionsRecentActivity(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UserRecentActivityFeed{}, err
|
||||
}
|
||||
for _, row := range lessons {
|
||||
at, ok := pgTimestamptzTime(row.OccurredAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, domain.UserRecentActivityItem{
|
||||
ID: fmt.Sprintf("lesson:%d:%d", row.LessonID, at.UnixNano()),
|
||||
Kind: domain.UserRecentActivityLessonCompleted,
|
||||
OccurredAt: at,
|
||||
Headline: headlineLessonUnit(row.ModuleSortOrder, row.LessonTitle),
|
||||
Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName},
|
||||
Course: &domain.RecentActivityCourseRef{ID: row.CourseID, Name: row.CourseName},
|
||||
Module: &domain.RecentActivityModuleRef{ID: row.ModuleID, Name: row.ModuleName, SortOrder: row.ModuleSortOrder},
|
||||
Lesson: &domain.RecentActivityLessonRef{ID: row.LessonID, Title: row.LessonTitle},
|
||||
})
|
||||
}
|
||||
|
||||
mods, err := s.store.ListUserModuleCompletionsRecentActivity(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UserRecentActivityFeed{}, err
|
||||
}
|
||||
for _, row := range mods {
|
||||
at, ok := pgTimestamptzTime(row.OccurredAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, domain.UserRecentActivityItem{
|
||||
ID: fmt.Sprintf("module:%d:%d", row.ModuleID, at.UnixNano()),
|
||||
Kind: domain.UserRecentActivityModuleCompleted,
|
||||
OccurredAt: at,
|
||||
Headline: headlineModuleUnit(row.ModuleSortOrder, row.ModuleName),
|
||||
Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName},
|
||||
Course: &domain.RecentActivityCourseRef{ID: row.CourseID, Name: row.CourseName},
|
||||
Module: &domain.RecentActivityModuleRef{ID: row.ModuleID, Name: row.ModuleName, SortOrder: row.ModuleSortOrder},
|
||||
})
|
||||
}
|
||||
|
||||
courses, err := s.store.ListUserCourseCompletionsRecentActivity(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UserRecentActivityFeed{}, err
|
||||
}
|
||||
for _, row := range courses {
|
||||
at, ok := pgTimestamptzTime(row.OccurredAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
headline := "Completed course"
|
||||
if row.CourseName != "" {
|
||||
headline = "Completed course: " + row.CourseName
|
||||
}
|
||||
items = append(items, domain.UserRecentActivityItem{
|
||||
ID: fmt.Sprintf("course:%d:%d", row.CourseID, at.UnixNano()),
|
||||
Kind: domain.UserRecentActivityCourseCompleted,
|
||||
OccurredAt: at,
|
||||
Headline: headline,
|
||||
Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName},
|
||||
Course: &domain.RecentActivityCourseRef{ID: row.CourseID, Name: row.CourseName},
|
||||
})
|
||||
}
|
||||
|
||||
progs, err := s.store.ListUserProgramCompletionsRecentActivity(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UserRecentActivityFeed{}, err
|
||||
}
|
||||
for _, row := range progs {
|
||||
at, ok := pgTimestamptzTime(row.OccurredAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
headline := "Completed learning path"
|
||||
if row.ProgramName != "" {
|
||||
headline = "Completed learning path: " + row.ProgramName
|
||||
}
|
||||
items = append(items, domain.UserRecentActivityItem{
|
||||
ID: fmt.Sprintf("program:%d:%d", row.ProgramID, at.UnixNano()),
|
||||
Kind: domain.UserRecentActivityProgramCompleted,
|
||||
OccurredAt: at,
|
||||
Headline: headline,
|
||||
Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName},
|
||||
})
|
||||
}
|
||||
|
||||
if includePractices {
|
||||
practices, err := s.store.ListUserPracticeCompletionsRecentActivity(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.UserRecentActivityFeed{}, err
|
||||
}
|
||||
for _, row := range practices {
|
||||
at, ok := pgTimestamptzTime(row.OccurredAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
headline := "Completed practice"
|
||||
if row.PracticeTitle != "" {
|
||||
headline = "Completed practice: " + row.PracticeTitle
|
||||
}
|
||||
item := domain.UserRecentActivityItem{
|
||||
ID: fmt.Sprintf("practice:%d:%d", row.LmsPracticeID, at.UnixNano()),
|
||||
Kind: domain.UserRecentActivityPracticeCompleted,
|
||||
OccurredAt: at,
|
||||
Headline: headline,
|
||||
Program: &domain.RecentActivityProgramRef{ID: row.ProgramID, Name: row.ProgramName},
|
||||
Course: &domain.RecentActivityCourseRef{ID: row.CourseID, Name: row.CourseName},
|
||||
Practice: &domain.RecentActivityPracticeRef{
|
||||
LMSPracticeID: row.LmsPracticeID,
|
||||
Title: row.PracticeTitle,
|
||||
Scope: row.Scope,
|
||||
},
|
||||
}
|
||||
if row.ModuleID != 0 {
|
||||
item.Module = &domain.RecentActivityModuleRef{
|
||||
ID: row.ModuleID, Name: row.ModuleName, SortOrder: row.ModuleSortOrder,
|
||||
}
|
||||
}
|
||||
if row.LessonID != 0 {
|
||||
item.Lesson = &domain.RecentActivityLessonRef{ID: row.LessonID, Title: row.LessonTitle}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ti, tj := items[i].OccurredAt, items[j].OccurredAt
|
||||
if !ti.Equal(tj) {
|
||||
return ti.After(tj)
|
||||
}
|
||||
ri, rj := kindRank(items[i].Kind), kindRank(items[j].Kind)
|
||||
if ri != rj {
|
||||
return ri > rj
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
|
||||
return domain.UserRecentActivityFeed{
|
||||
UserID: userID,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"Yimaru-Backend/internal/domain"
|
||||
|
|
@ -73,3 +74,69 @@ func (h *Handler) AdminGetUserLMSLearningActivity(c *fiber.Ctx) error {
|
|||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminGetUserRecentActivity godoc
|
||||
// @Summary Recent activity timeline for a user (admin)
|
||||
// @Description Reverse-chronological feed for profile UI: account joined plus LMS completion milestones (lessons/modules/courses/programs). Optional practice completions via include_practices. Does not include "started learning path" unless you add persisted engagement events—the schema stores completions only.
|
||||
// @Tags lms
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param user_id path int true "Target user ID"
|
||||
// @Param limit query int false "Max items after merge (default 40, max 120)"
|
||||
// @Param include_practices query bool false "Include completed LMS practices (more verbose)"
|
||||
// @Success 200 {object} domain.Response
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 404 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/admin/users/{user_id}/recent-activity [get]
|
||||
func (h *Handler) AdminGetUserRecentActivity(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",
|
||||
})
|
||||
}
|
||||
|
||||
limit := 40
|
||||
if ls := c.Query("limit"); ls != "" {
|
||||
n, err := strconv.Atoi(ls)
|
||||
if err != nil || n < 1 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid limit",
|
||||
Error: "limit must be a positive integer",
|
||||
})
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
|
||||
includePractices := c.Query("include_practices") == "true" || c.Query("include_practices") == "1"
|
||||
|
||||
feed, err := h.lmsProgressSvc.AdminUserRecentActivity(c.Context(), targetID, limit, includePractices)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrUserNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
|
||||
Message: "User not found",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to load recent activity",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(domain.Response{
|
||||
Message: "Recent activity retrieved successfully",
|
||||
Data: feed,
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ func (a *App) initAppRoutes() {
|
|||
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/:user_id/lms-learning-activity", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.AdminGetUserLMSLearningActivity)
|
||||
groupV1.Get("/admin/users/:user_id/recent-activity", a.authMiddleware, a.RequirePermission("progress.get_any_user"), h.AdminGetUserRecentActivity)
|
||||
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.Patch("/user/status", a.authMiddleware, a.RequirePermission("users.update_status"), h.UpdateUserStatus)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user