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;
|
updated_at;
|
||||||
|
|
||||||
|
|
||||||
|
-- name: GetUserCreatedAt :one
|
||||||
|
SELECT created_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetUserByID :one
|
-- name: GetUserByID :one
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM users
|
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}": {
|
"/api/v1/admin/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Get a single admin by id",
|
"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}": {
|
"/api/v1/admin/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Get a single admin by id",
|
"description": "Get a single admin by id",
|
||||||
|
|
|
||||||
|
|
@ -2900,6 +2900,50 @@ paths:
|
||||||
summary: Get a user's nested LMS learning activity (admin)
|
summary: Get a user's nested LMS learning activity (admin)
|
||||||
tags:
|
tags:
|
||||||
- lms
|
- 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:
|
/api/v1/admin/users/deletion-requests:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
|
|
|
||||||
|
|
@ -819,6 +819,19 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||||
return i, err
|
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
|
const GetUserSummary = `-- name: GetUserSummary :one
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS total_users,
|
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
|
}, 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(
|
func (s *Store) GetUserByGoogleID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
googleId string,
|
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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
|
|
@ -73,3 +74,69 @@ func (h *Handler) AdminGetUserLMSLearningActivity(c *fiber.Ctx) error {
|
||||||
StatusCode: fiber.StatusOK,
|
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("/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("/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.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