From a80db8afd9542cc733f062a8cec676cfdeea5f1a Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Mon, 18 May 2026 01:13:21 -0700 Subject: [PATCH] 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 --- db/query/user.sql | 5 + db/query/user_recent_activity.sql | 173 ++++++++ docs/docs.go | 64 +++ docs/swagger.json | 64 +++ docs/swagger.yaml | 44 ++ gen/db/user.sql.go | 13 + gen/db/user_recent_activity.sql.go | 385 ++++++++++++++++++ internal/domain/user_recent_activity.go | 67 +++ internal/repository/user.go | 12 + internal/repository/user_recent_activity.go | 27 ++ .../lmsprogress/admin_recent_activity.go | 227 +++++++++++ .../handlers/lms_progress_handler.go | 67 +++ internal/web_server/routes.go | 1 + 13 files changed, 1149 insertions(+) create mode 100644 db/query/user_recent_activity.sql create mode 100644 gen/db/user_recent_activity.sql.go create mode 100644 internal/domain/user_recent_activity.go create mode 100644 internal/repository/user_recent_activity.go create mode 100644 internal/services/lmsprogress/admin_recent_activity.go diff --git a/db/query/user.sql b/db/query/user.sql index 3b46a88..51dd3c7 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -141,6 +141,11 @@ RETURNING updated_at; +-- name: GetUserCreatedAt :one +SELECT created_at +FROM users +WHERE id = $1; + -- name: GetUserByID :one SELECT * FROM users diff --git a/db/query/user_recent_activity.sql b/db/query/user_recent_activity.sql new file mode 100644 index 0000000..dc5db21 --- /dev/null +++ b/db/query/user_recent_activity.sql @@ -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; diff --git a/docs/docs.go b/docs/docs.go index d0c4d89..6b34972 100644 --- a/docs/docs.go +++ b/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", diff --git a/docs/swagger.json b/docs/swagger.json index b18203c..b832559 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f61bac9..780aa52 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 487c3b6..419aff2 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -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, diff --git a/gen/db/user_recent_activity.sql.go b/gen/db/user_recent_activity.sql.go new file mode 100644 index 0000000..ca50f20 --- /dev/null +++ b/gen/db/user_recent_activity.sql.go @@ -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 +} diff --git a/internal/domain/user_recent_activity.go b/internal/domain/user_recent_activity.go new file mode 100644 index 0000000..6eed01d --- /dev/null +++ b/internal/domain/user_recent_activity.go @@ -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 +} diff --git a/internal/repository/user.go b/internal/repository/user.go index 9419f97..c6ae93a 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -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, diff --git a/internal/repository/user_recent_activity.go b/internal/repository/user_recent_activity.go new file mode 100644 index 0000000..b1b2630 --- /dev/null +++ b/internal/repository/user_recent_activity.go @@ -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) +} diff --git a/internal/services/lmsprogress/admin_recent_activity.go b/internal/services/lmsprogress/admin_recent_activity.go new file mode 100644 index 0000000..c539076 --- /dev/null +++ b/internal/services/lmsprogress/admin_recent_activity.go @@ -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 +} diff --git a/internal/web_server/handlers/lms_progress_handler.go b/internal/web_server/handlers/lms_progress_handler.go index 30e1e16..de69170 100644 --- a/internal/web_server/handlers/lms_progress_handler.go +++ b/internal/web_server/handlers/lms_progress_handler.go @@ -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, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index f9b4d87..04f4926 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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)