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:
Yared Yemane 2026-05-18 01:13:21 -07:00
parent 52effaa321
commit a80db8afd9
13 changed files with 1149 additions and 0 deletions

View File

@ -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

View 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;

View File

@ -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",

View File

@ -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",

View File

@ -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:

View File

@ -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,

View 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
}

View 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
}

View File

@ -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,

View 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)
}

View 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
}

View File

@ -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,
})
}

View File

@ -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)