From 255d3a333a1b359097a21beb310cce4b23a43918 Mon Sep 17 00:00:00 2001 From: rafmaster7 Date: Wed, 16 Sep 2026 17:47:00 +0200 Subject: [PATCH 1/3] feat: community activity log for admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an admin-only "Activity log" (Admin → Community → Activity log) that records what users do, with filters and a TSV export: - activity_log table (created on start-up), async write queue - the base queue accepts several handlers, so the event queue now feeds both the badge rules and the log - events: question/answer/comment create, update, delete, vote (with direction and cancel), accept, flag, react, comment replies - hooks where no event exists: every login (method, IP, UA), badge awards, review queue (queued / approved / rejected), every reputation change (with the object and activity that caused it), role changes, suspend / unsuspend / delete - page views sent by the UI on route change (deduplicated), kept for ACTIVITY_LOG_PAGEVIEW_DAYS days (default 90, cleaned by cron) - admin API: paged list with date range / user / action / free-text filters, action counts, TSV export (UTF-8 BOM, streamed, 100k cap) - admin UI page with date presets, user suggestions, multi-select action filter, search, export; all filters live in the URL 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0181uZ2px83HmPo3HKSEzWQR Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0181uZ2px83HmPo3HKSEzWQR --- cmd/wire_gen.go | 55 +- i18n/en_US.yaml | 79 +++ internal/base/cron/cron.go | 18 + internal/base/queue/queue.go | 32 +- internal/cli/reset_password.go | 2 +- .../controller/activity_log_controller.go | 57 ++ internal/controller/controller.go | 1 + .../activity_log_controller.go | 118 ++++ internal/controller_admin/controller.go | 1 + internal/entity/activity_log_entity.go | 46 ++ internal/repo/activity/answer_repo.go | 5 +- internal/repo/activity/review_repo.go | 3 +- internal/repo/activity/user_active_repo.go | 3 +- internal/repo/activity/vote_repo.go | 5 +- .../repo/activity_log/activity_log_repo.go | 184 ++++++ internal/repo/provider.go | 2 + internal/repo/rank/user_rank_repo.go | 39 +- internal/repo/user/user_repo.go | 32 +- internal/router/answer_api_router.go | 12 + internal/schema/activity_log_schema.go | 75 +++ .../activity_log_admin_service.go | 305 ++++++++++ .../activity_log/activity_log_service.go | 388 +++++++++++++ .../activity_log/activity_log_service_test.go | 140 +++++ internal/service/activity_log/rank_context.go | 44 ++ internal/service/badge/badge_award_service.go | 7 + internal/service/content/vote_service.go | 55 +- internal/service/provider.go | 3 + internal/service/review/review_service.go | 30 + internal/service/user_admin/user_backyard.go | 19 + ui/src/common/constants.ts | 1 + ui/src/pages/Admin/ActivityLog/index.tsx | 537 ++++++++++++++++++ ui/src/pages/Layout/index.tsx | 15 +- ui/src/router/routes.ts | 4 + ui/src/services/admin/activityLog.ts | 122 ++++ ui/src/services/admin/index.ts | 1 + 35 files changed, 2369 insertions(+), 71 deletions(-) create mode 100644 internal/controller/activity_log_controller.go create mode 100644 internal/controller_admin/activity_log_controller.go create mode 100644 internal/entity/activity_log_entity.go create mode 100644 internal/repo/activity_log/activity_log_repo.go create mode 100644 internal/schema/activity_log_schema.go create mode 100644 internal/service/activity_log/activity_log_admin_service.go create mode 100644 internal/service/activity_log/activity_log_service.go create mode 100644 internal/service/activity_log/activity_log_service_test.go create mode 100644 internal/service/activity_log/rank_context.go create mode 100644 ui/src/pages/Admin/ActivityLog/index.tsx create mode 100644 ui/src/services/admin/activityLog.ts diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 446f6cc0b..64126e3bc 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -38,6 +38,7 @@ import ( "github.com/apache/answer/internal/controller_admin" "github.com/apache/answer/internal/repo/activity" "github.com/apache/answer/internal/repo/activity_common" + "github.com/apache/answer/internal/repo/activity_log" "github.com/apache/answer/internal/repo/ai_conversation" "github.com/apache/answer/internal/repo/answer" "github.com/apache/answer/internal/repo/api_key" @@ -74,6 +75,7 @@ import ( "github.com/apache/answer/internal/service/action" activity2 "github.com/apache/answer/internal/service/activity" activity_common2 "github.com/apache/answer/internal/service/activity_common" + activity_log2 "github.com/apache/answer/internal/service/activity_log" "github.com/apache/answer/internal/service/activityqueue" ai_conversation2 "github.com/apache/answer/internal/service/ai_conversation" "github.com/apache/answer/internal/service/answer_common" @@ -153,12 +155,16 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, authRepo := auth.NewAuthRepo(dataData) apiKeyRepo := api_key.NewAPIKeyRepo(dataData) authService := auth2.NewAuthService(authRepo, apiKeyRepo) - userRepo := user.NewUserRepo(dataData) + activityLogRepo := activity_log.NewActivityLogRepo(dataData) uniqueIDRepo := unique.NewUniqueIDRepo(dataData) + commentCommonRepo := comment.NewCommentCommonRepo(dataData, uniqueIDRepo) + service := eventqueue.NewService() + activityLogService := activity_log2.NewActivityLogService(activityLogRepo, commentCommonRepo, service) + userRepo := user.NewUserRepo(dataData, activityLogService) configRepo := config.NewConfigRepo(dataData) configService := config2.NewConfigService(configRepo) activityRepo := activity_common.NewActivityRepo(dataData, uniqueIDRepo, configService) - userRankRepo := rank.NewUserRankRepo(dataData, configService) + userRankRepo := rank.NewUserRankRepo(dataData, configService, activityLogService) userActiveActivityRepo := activity.NewUserActiveActivityRepo(dataData, activityRepo, userRankRepo, configService) emailRepo := export.NewEmailRepo(dataData) emailService := export2.NewEmailService(configService, emailRepo, siteInfoCommonService) @@ -180,30 +186,28 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, tagRepo := tag.NewTagRepo(dataData, uniqueIDRepo) revisionRepo := revision.NewRevisionRepo(dataData, uniqueIDRepo) revisionService := revision_common.NewRevisionService(revisionRepo, userRepo) - service := activityqueue.NewService() - tagCommonService := tag_common2.NewTagCommonService(tagCommonRepo, tagRelRepo, tagRepo, revisionService, siteInfoCommonService, service) + activityqueueService := activityqueue.NewService() + tagCommonService := tag_common2.NewTagCommonService(tagCommonRepo, tagRelRepo, tagRepo, revisionService, siteInfoCommonService, activityqueueService) collectionRepo := collection.NewCollectionRepo(dataData, uniqueIDRepo) collectionCommon := collectioncommon.NewCollectionCommon(collectionRepo) answerCommon := answercommon.NewAnswerCommon(answerRepo) metaRepo := meta.NewMetaRepo(dataData) metaCommonService := metacommon.NewMetaCommonService(metaRepo) - questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaCommonService, configService, service, revisionRepo, siteInfoCommonService, dataData) - eventqueueService := eventqueue.NewService() + questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaCommonService, configService, activityqueueService, revisionRepo, siteInfoCommonService, dataData) fileRecordRepo := file_record.NewFileRecordRepo(dataData) fileRecordService := file_record2.NewFileRecordService(fileRecordRepo, revisionRepo, serviceConf, siteInfoCommonService, userCommon) - userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon, eventqueueService, fileRecordService) + userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon, service, fileRecordService) captchaRepo := captcha.NewCaptchaRepo(dataData) captchaService := action.NewCaptchaService(captchaRepo) userController := controller.NewUserController(authService, userService, captchaService, emailService, siteInfoCommonService, userNotificationConfigService) commentRepo := comment.NewCommentRepo(dataData, uniqueIDRepo) - commentCommonRepo := comment.NewCommentCommonRepo(dataData, uniqueIDRepo) objService := object_info.NewObjService(answerRepo, questionRepo, commentCommonRepo, tagCommonRepo, tagCommonService) noticequeueService := noticequeue.NewService() externalService := noticequeue.NewExternalService() reviewRepo := review.NewReviewRepo(dataData) vector_syncService := vector_sync.NewService(dataData) - reviewService := review2.NewReviewService(reviewRepo, objService, userCommon, userRepo, questionRepo, answerRepo, userRoleRelService, externalService, tagCommonService, questionCommon, noticequeueService, siteInfoCommonService, commentCommonRepo, vector_syncService) - commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, noticequeueService, externalService, service, eventqueueService, reviewService, vector_syncService) + reviewService := review2.NewReviewService(reviewRepo, objService, userCommon, userRepo, questionRepo, answerRepo, userRoleRelService, externalService, tagCommonService, questionCommon, noticequeueService, siteInfoCommonService, commentCommonRepo, vector_syncService, activityLogService) + commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, noticequeueService, externalService, activityqueueService, service, reviewService, vector_syncService) rolePowerRelRepo := role.NewRolePowerRelRepo(dataData) rolePowerRelService := role2.NewRolePowerRelService(rolePowerRelRepo, userRoleRelService) rankService := rank2.NewRankService(userCommon, userRankRepo, objService, userRoleRelService, rolePowerRelService, configService) @@ -211,17 +215,17 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, rateLimitMiddleware := middleware.NewRateLimitMiddleware(limitRepo) commentController := controller.NewCommentController(commentService, rankService, captchaService, rateLimitMiddleware) reportRepo := report.NewReportRepo(dataData, uniqueIDRepo) - tagService := tag2.NewTagService(tagRepo, tagCommonService, revisionService, followRepo, siteInfoCommonService, service) + tagService := tag2.NewTagService(tagRepo, tagCommonService, revisionService, followRepo, siteInfoCommonService, activityqueueService) answerActivityRepo := activity.NewAnswerActivityRepo(dataData, activityRepo, userRankRepo, noticequeueService) answerActivityService := activity2.NewAnswerActivityService(answerActivityRepo, configService) externalNotificationService := notification.NewExternalNotificationService(dataData, userNotificationConfigRepo, followRepo, emailService, userRepo, externalService, userExternalLoginRepo, siteInfoCommonService) - questionService := content.NewQuestionService(activityRepo, questionRepo, answerRepo, tagCommonService, tagService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, noticequeueService, externalService, service, siteInfoCommonService, externalNotificationService, reviewService, configService, eventqueueService, reviewRepo, vector_syncService) - answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, noticequeueService, externalService, service, reviewService, eventqueueService, vector_syncService) + questionService := content.NewQuestionService(activityRepo, questionRepo, answerRepo, tagCommonService, tagService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, noticequeueService, externalService, activityqueueService, siteInfoCommonService, externalNotificationService, reviewService, configService, service, reviewRepo, vector_syncService) + answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, noticequeueService, externalService, activityqueueService, reviewService, service, vector_syncService) reportHandle := report_handle.NewReportHandle(questionService, answerService, commentService) - reportService := report2.NewReportService(reportRepo, objService, userCommon, answerRepo, questionRepo, commentCommonRepo, reportHandle, configService, eventqueueService) + reportService := report2.NewReportService(reportRepo, objService, userCommon, answerRepo, questionRepo, commentCommonRepo, reportHandle, configService, service) reportController := controller.NewReportController(reportService, rankService, captchaService) contentVoteRepo := activity.NewVoteRepo(dataData, activityRepo, userRankRepo, noticequeueService) - voteService := content.NewVoteService(contentVoteRepo, configService, questionRepo, answerRepo, commentCommonRepo, objService, eventqueueService) + voteService := content.NewVoteService(contentVoteRepo, configService, questionRepo, answerRepo, commentCommonRepo, objService, service, activityLogService) voteController := controller.NewVoteController(voteService, rankService, captchaService) tagController := controller.NewTagController(tagService, tagCommonService, rankService) followFollowRepo := activity.NewFollowRepo(dataData, uniqueIDRepo, activityRepo) @@ -237,14 +241,14 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, searchService := content.NewSearchService(searchParser, searchRepo) searchController := controller.NewSearchController(searchService, captchaService) reviewActivityRepo := activity.NewReviewActivityRepo(dataData, activityRepo, userRankRepo, configService) - contentRevisionService := content.NewRevisionService(revisionRepo, userCommon, questionCommon, answerService, objService, questionRepo, answerRepo, tagRepo, tagCommonService, noticequeueService, service, reportRepo, reviewService, reviewActivityRepo) + contentRevisionService := content.NewRevisionService(revisionRepo, userCommon, questionCommon, answerService, objService, questionRepo, answerRepo, tagRepo, tagCommonService, noticequeueService, activityqueueService, reportRepo, reviewService, reviewActivityRepo) revisionController := controller.NewRevisionController(contentRevisionService, rankService) rankController := controller.NewRankController(rankService) userAdminRepo := user.NewUserAdminRepo(dataData, authRepo) notificationRepo := notification2.NewNotificationRepo(dataData) pluginUserConfigRepo := plugin_config.NewPluginUserConfigRepo(dataData) badgeAwardRepo := badge_award.NewBadgeAwardRepo(dataData, uniqueIDRepo) - userAdminService := user_admin.NewUserAdminService(userAdminRepo, userRoleRelService, authService, userCommon, userActiveActivityRepo, siteInfoCommonService, emailService, questionRepo, answerRepo, commentCommonRepo, userExternalLoginRepo, notificationRepo, pluginUserConfigRepo, badgeAwardRepo, apiKeyRepo) + userAdminService := user_admin.NewUserAdminService(userAdminRepo, userRoleRelService, authService, userCommon, userActiveActivityRepo, siteInfoCommonService, emailService, questionRepo, answerRepo, commentCommonRepo, userExternalLoginRepo, notificationRepo, pluginUserConfigRepo, badgeAwardRepo, apiKeyRepo, activityLogService) userAdminController := controller_admin.NewUserAdminController(userAdminService) reasonRepo := reason.NewReasonRepo(configService) reasonService := reason2.NewReasonService(reasonRepo) @@ -262,7 +266,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, uploaderService := uploader.NewUploaderService(serviceConf, siteInfoCommonService, fileRecordService) uploadController := controller.NewUploadController(uploaderService) activityActivityRepo := activity.NewActivityRepo(dataData, configService) - activityCommon := activity_common2.NewActivityCommon(activityRepo, service) + activityCommon := activity_common2.NewActivityCommon(activityRepo, activityqueueService) commentCommonService := comment_common.NewCommentCommonService(commentCommonRepo) activityService := activity2.NewActivityService(activityActivityRepo, userCommon, activityCommon, tagCommonService, objService, commentCommonService, revisionService, metaCommonService, configService) activityController := controller.NewActivityController(activityService) @@ -274,12 +278,12 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, permissionController := controller.NewPermissionController(rankService) userPluginController := controller.NewUserPluginController(pluginCommonService) reviewController := controller.NewReviewController(reviewService, rankService, captchaService) - metaService := meta2.NewMetaService(metaCommonService, userCommon, answerRepo, questionRepo, eventqueueService) + metaService := meta2.NewMetaService(metaCommonService, userCommon, answerRepo, questionRepo, service) metaController := controller.NewMetaController(metaService) badgeGroupRepo := badge_group.NewBadgeGroupRepo(dataData, uniqueIDRepo) eventRuleRepo := badge.NewEventRuleRepo(dataData) - badgeAwardService := badge2.NewBadgeAwardService(badgeAwardRepo, badgeRepo, userCommon, objService, noticequeueService) - badgeEventService := badge2.NewBadgeEventService(dataData, eventqueueService, badgeRepo, eventRuleRepo, badgeAwardService) + badgeAwardService := badge2.NewBadgeAwardService(badgeAwardRepo, badgeRepo, userCommon, objService, noticequeueService, activityLogService) + badgeEventService := badge2.NewBadgeEventService(dataData, service, badgeRepo, eventRuleRepo, badgeAwardService) badgeService := badge2.NewBadgeService(badgeRepo, badgeGroupRepo, badgeAwardRepo, badgeEventService, siteInfoCommonService) badgeController := controller.NewBadgeController(badgeService, badgeAwardService) controller_adminBadgeController := controller_admin.NewBadgeController(badgeService) @@ -293,14 +297,17 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, aiController := controller.NewAIController(searchService, siteInfoCommonService, tagCommonService, questionCommon, commentRepo, userCommon, answerRepo, mcpController, aiConversationService, featureToggleService) aiConversationController := controller.NewAIConversationController(aiConversationService, featureToggleService) aiConversationAdminController := controller_admin.NewAIConversationAdminController(aiConversationService, featureToggleService) - answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController, permissionController, userPluginController, reviewController, metaController, badgeController, controller_adminBadgeController, adminAPIKeyController, aiController, aiConversationController, aiConversationAdminController, mcpController) + activityLogAdminService := activity_log2.NewActivityLogAdminService(activityLogService, activityLogRepo, objService, userCommon, configService) + activityLogController := controller.NewActivityLogController(activityLogAdminService) + controller_adminActivityLogController := controller_admin.NewActivityLogController(activityLogAdminService) + answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController, permissionController, userPluginController, reviewController, metaController, badgeController, controller_adminBadgeController, adminAPIKeyController, aiController, aiConversationController, aiConversationAdminController, mcpController, activityLogController, controller_adminActivityLogController) swaggerRouter := router.NewSwaggerRouter(swaggerConf) uiRouter := router.NewUIRouter(controllerSiteInfoController, siteInfoCommonService) authUserMiddleware := middleware.NewAuthUserMiddleware(authService, siteInfoCommonService) avatarMiddleware := middleware.NewAvatarMiddleware(serviceConf, uploaderService) shortIDMiddleware := middleware.NewShortIDMiddleware(siteInfoCommonService) templateRenderController := templaterender.NewTemplateRenderController(questionService, userService, tagService, answerService, commentService, siteInfoCommonService, questionRepo) - templateController := controller.NewTemplateController(templateRenderController, siteInfoCommonService, eventqueueService, userService, questionService) + templateController := controller.NewTemplateController(templateRenderController, siteInfoCommonService, service, userService, questionService) templateRouter := router.NewTemplateRouter(templateController, templateRenderController, siteInfoController, authUserMiddleware) connectorController := controller.NewConnectorController(siteInfoCommonService, emailService, userExternalLoginService) userCenterLoginService := user_external_login2.NewUserCenterLoginService(userRepo, userCommon, userExternalLoginRepo, userActiveActivityRepo, siteInfoCommonService) @@ -311,7 +318,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, sidebarController := controller.NewSidebarController() pluginAPIRouter := router.NewPluginAPIRouter(connectorController, userCenterController, captchaController, embedController, renderController, sidebarController) ginEngine := server.NewHTTPServer(debug, staticRouter, answerAPIRouter, swaggerRouter, uiRouter, authUserMiddleware, avatarMiddleware, shortIDMiddleware, templateRouter, pluginAPIRouter, uiConf) - scheduledTaskManager := cron.NewScheduledTaskManager(siteInfoCommonService, questionService, fileRecordService, userAdminService, serviceConf) + scheduledTaskManager := cron.NewScheduledTaskManager(siteInfoCommonService, questionService, fileRecordService, userAdminService, serviceConf, activityLogService) application := newApplication(serverConf, ginEngine, scheduledTaskManager) return application, func() { cleanup2() diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 5d1faa3e0..c512f2d9b 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -1840,6 +1840,7 @@ ui: ai_assistant: AI Assistant ai_settings: AI Settings mcp: MCP + activity_log: Activity log website_welcome: Welcome to {{site_name}} user_center: login: Login @@ -2304,6 +2305,84 @@ ui: msg: should_be_number: the input should be number number_larger_1: number should be equal or larger than 1 + activity_log: + title: Activity log + total: "{{count}} entries" + system: System + export: Export TSV + exporting: Exporting… + export_failed: Export failed + hint: Page views are kept for 90 days; other entries are kept permanently. Times are shown in your local time zone. + range: + today: Today + yesterday: Yesterday + 7d: 7 days + 30d: 30 days + custom: Custom + filter: + range: Date range + user: User + user_placeholder: username + action: Action + action_all: All actions + action_selected: "{{count}} selected" + action_clear: Clear selection + action_none: No entries in this range + search: Search + search_placeholder: any text from the grid + col: + time: Time + user: User + action: Action + object: Object / details + target: Target + points: Points + detail: + reason: reason + direction: direction + method: method + role: role + reply_to: reply to + action: + user_login: Logged in + user_update: Profile updated + user_share: Shared a link + user_role: Role changed + user_suspend: User suspended + user_unsuspend: User unsuspended + user_delete: User deleted + page_view: Page view + badge_award: Badge awarded + badge_revoke: Badge revoked + review_queued: Sent to review + review_approve: Review approved + review_reject: Review rejected + reputation_change: Reputation + comment_reply: Replied to comment + question_create: Question asked + question_update: Question edited + question_delete: Question deleted + question_vote_up: Question upvoted + question_vote_down: Question downvoted + question_vote_cancel: Question vote withdrawn + question_accept: Answer accepted + question_flag: Question flagged + question_react: Question reaction + answer_create: Answer posted + answer_update: Answer edited + answer_delete: Answer deleted + answer_vote_up: Answer upvoted + answer_vote_down: Answer downvoted + answer_vote_cancel: Answer vote withdrawn + answer_flag: Answer flagged + answer_react: Answer reaction + comment_create: Comment posted + comment_update: Comment edited + comment_delete: Comment deleted + comment_vote_up: Comment upvoted + comment_vote_down: Comment downvoted + comment_vote_cancel: Comment vote withdrawn + comment_flag: Comment flagged badges: action: Action active: Active diff --git a/internal/base/cron/cron.go b/internal/base/cron/cron.go index 1d8008ad6..6336a0237 100644 --- a/internal/base/cron/cron.go +++ b/internal/base/cron/cron.go @@ -22,12 +22,15 @@ package cron import ( "context" "fmt" + "os" + "github.com/apache/answer/internal/service/activity_log" "github.com/apache/answer/internal/service/content" "github.com/apache/answer/internal/service/file_record" "github.com/apache/answer/internal/service/service_config" "github.com/apache/answer/internal/service/siteinfo_common" "github.com/apache/answer/internal/service/user_admin" + "github.com/apache/answer/pkg/converter" "github.com/robfig/cron/v3" "github.com/segmentfault/pacman/log" ) @@ -39,6 +42,7 @@ type ScheduledTaskManager struct { fileRecordService *file_record.FileRecordService userAdminService *user_admin.UserAdminService serviceConfig *service_config.ServiceConfig + activityLog *activity_log.ActivityLogService } // NewScheduledTaskManager new scheduled task manager @@ -48,8 +52,10 @@ func NewScheduledTaskManager( fileRecordService *file_record.FileRecordService, userAdminService *user_admin.UserAdminService, serviceConfig *service_config.ServiceConfig, + activityLog *activity_log.ActivityLogService, ) *ScheduledTaskManager { manager := &ScheduledTaskManager{ + activityLog: activityLog, siteInfoService: siteInfoService, questionService: questionService, fileRecordService: fileRecordService, @@ -82,6 +88,18 @@ func (s *ScheduledTaskManager) Run() { log.Error(err) } + // activity log: page views are the bulk of the table → keep ACTIVITY_LOG_PAGEVIEW_DAYS days (default 90, 0 = forever) + pageViewDays := 90 + if v := os.Getenv("ACTIVITY_LOG_PAGEVIEW_DAYS"); v != "" { + pageViewDays = converter.StringToInt(v) + } + _, err = c.AddFunc("30 3 * * *", func() { + s.activityLog.CleanupPageViews(context.Background(), pageViewDays) + }) + if err != nil { + log.Error(err) + } + // Check for expired user suspensions every 10 minutes _, err = c.AddFunc("*/10 * * * *", func() { ctx := context.Background() diff --git a/internal/base/queue/queue.go b/internal/base/queue/queue.go index 4389c2925..9b1db4bec 100644 --- a/internal/base/queue/queue.go +++ b/internal/base/queue/queue.go @@ -30,7 +30,8 @@ type Service[T any] interface { // Send enqueues a message to be processed asynchronously. Send(ctx context.Context, msg T) - // RegisterHandler sets the handler function for processing messages. + // RegisterHandler adds a handler function for processing messages. + // Every registered handler receives every message, in registration order. RegisterHandler(handler func(ctx context.Context, msg T) error) // Close gracefully shuts down the queue, waiting for pending messages to be processed. @@ -40,12 +41,12 @@ type Service[T any] interface { // Queue is a generic message queue service that processes messages asynchronously. // It is thread-safe and supports graceful shutdown. type Queue[T any] struct { - name string - queue chan T - handler func(ctx context.Context, msg T) error - mu sync.RWMutex - closed bool - wg sync.WaitGroup + name string + queue chan T + handlers []func(ctx context.Context, msg T) error + mu sync.RWMutex + closed bool + wg sync.WaitGroup } // New creates a new queue with the given name and buffer size. @@ -77,12 +78,13 @@ func (q *Queue[T]) Send(ctx context.Context, msg T) { } } -// RegisterHandler sets the handler function for processing messages. -// This is thread-safe and can be called at any time. +// RegisterHandler adds a handler function for processing messages. +// This is thread-safe and can be called at any time. Several subscribers may +// listen to the same queue (badges and the activity log both consume the event queue). func (q *Queue[T]) RegisterHandler(handler func(ctx context.Context, msg T) error) { q.mu.Lock() defer q.mu.Unlock() - q.handler = handler + q.handlers = append(q.handlers, handler) } // Close gracefully shuts down the queue, waiting for pending messages to be processed. @@ -112,17 +114,19 @@ func (q *Queue[T]) startWorker() { // processMessage handles a single message with proper synchronization. func (q *Queue[T]) processMessage(msg T) { q.mu.RLock() - handler := q.handler + handlers := q.handlers q.mu.RUnlock() - if handler == nil { + if len(handlers) == 0 { log.Warnf("[%s] no handler registered, dropping message: %+v", q.name, msg) return } // Use background context for async processing // TODO: Consider adding timeout or using a derived context - if err := handler(context.TODO(), msg); err != nil { - log.Errorf("[%s] handler error: %v", q.name, err) + for _, handler := range handlers { + if err := handler(context.TODO(), msg); err != nil { + log.Errorf("[%s] handler error: %v", q.name, err) + } } } diff --git a/internal/cli/reset_password.go b/internal/cli/reset_password.go index 00ffe911b..9b688859e 100644 --- a/internal/cli/reset_password.go +++ b/internal/cli/reset_password.go @@ -94,7 +94,7 @@ func ResetPassword(ctx context.Context, dataDirPath string, opts *ResetPasswordO } defer dataCleanup() - userRepo := user.NewUserRepo(dataData) + userRepo := user.NewUserRepo(dataData, nil) // CLI: no activity log authRepo := auth.NewAuthRepo(dataData) apiKeyRepo := api_key.NewAPIKeyRepo(dataData) authSvc := authService.NewAuthService(authRepo, apiKeyRepo) diff --git a/internal/controller/activity_log_controller.go b/internal/controller/activity_log_controller.go new file mode 100644 index 000000000..f58a92e02 --- /dev/null +++ b/internal/controller/activity_log_controller.go @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/base/middleware" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/activity_log" + "github.com/gin-gonic/gin" +) + +// ActivityLogController page-view beacon from the UI +type ActivityLogController struct { + activityLogAdminService *activity_log.ActivityLogAdminService +} + +// NewActivityLogController new controller +func NewActivityLogController(activityLogAdminService *activity_log.ActivityLogAdminService) *ActivityLogController { + return &ActivityLogController{activityLogAdminService: activityLogAdminService} +} + +// PageView record that the logged-in user opened a page +// @Summary record a page view +// @Tags activity-log +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.ActivityLogPageViewReq true "page" +// @Success 200 {object} handler.RespBody +// @Router /answer/api/v1/activity-log/view [post] +func (c *ActivityLogController) PageView(ctx *gin.Context) { + req := &schema.ActivityLogPageViewReq{} + if handler.BindAndCheck(ctx, req) { + return + } + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + c.activityLogAdminService.PageView(ctx, req) + handler.HandleResponse(ctx, nil, nil) +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go index c31763bea..065b95d8c 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -52,6 +52,7 @@ var ProviderSetController = wire.NewSet( NewMetaController, NewEmbedController, NewBadgeController, + NewActivityLogController, NewRenderController, NewSidebarController, NewMCPController, diff --git a/internal/controller_admin/activity_log_controller.go b/internal/controller_admin/activity_log_controller.go new file mode 100644 index 000000000..d1274690d --- /dev/null +++ b/internal/controller_admin/activity_log_controller.go @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller_admin + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/base/translator" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/activity_log" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/log" +) + +// ActivityLogController admin community activity log +type ActivityLogController struct { + activityLogAdminService *activity_log.ActivityLogAdminService +} + +// NewActivityLogController new controller +func NewActivityLogController(activityLogAdminService *activity_log.ActivityLogAdminService) *ActivityLogController { + return &ActivityLogController{activityLogAdminService: activityLogAdminService} +} + +// GetActivityLogPage activity log page +// @Summary activity log page +// @Description activity log page (admin) +// @Tags admin +// @Produce json +// @Security ApiKeyAuth +// @Param page query int false "page" +// @Param page_size query int false "page size" +// @Param from query int false "from (unix seconds)" +// @Param to query int false "to (unix seconds, exclusive)" +// @Param username query string false "actor or target username" +// @Param action query string false "comma separated action keys" +// @Param q query string false "free text" +// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.ActivityLogItem}} +// @Router /answer/admin/api/activity-log [get] +func (c *ActivityLogController) GetActivityLogPage(ctx *gin.Context) { + req := &schema.ActivityLogPageReq{} + if handler.BindAndCheck(ctx, req) { + return + } + resp, err := c.activityLogAdminService.Page(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// GetActivityLogActions action keys with counts in the date range +// @Summary activity log actions +// @Tags admin +// @Produce json +// @Security ApiKeyAuth +// @Param from query int false "from (unix seconds)" +// @Param to query int false "to (unix seconds, exclusive)" +// @Success 200 {object} handler.RespBody{data=[]schema.ActivityLogActionCount} +// @Router /answer/admin/api/activity-log/actions [get] +func (c *ActivityLogController) GetActivityLogActions(ctx *gin.Context) { + req := &schema.ActivityLogPageReq{} + if handler.BindAndCheck(ctx, req) { + return + } + resp, err := c.activityLogAdminService.Actions(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// ExportActivityLog TSV export with the same filters as the page +// @Summary activity log export (TSV) +// @Tags admin +// @Produce text/tab-separated-values +// @Security ApiKeyAuth +// @Router /answer/admin/api/activity-log/export [get] +func (c *ActivityLogController) ExportActivityLog(ctx *gin.Context) { + req := &schema.ActivityLogPageReq{} + if handler.BindAndCheck(ctx, req) { + return + } + lang := handler.GetLangByCtx(ctx) + labels := map[string]string{} + for _, a := range activity_log.KnownActions { + labels[a] = translator.Tr(lang, "ui.admin.activity_log.action."+strings.ReplaceAll(a, ".", "_")) + } + name := fmt.Sprintf("log-aktywnosci-%s-%s.tsv", dayOf(req.From, time.Now()), dayOf(req.To-1, time.Now())) + ctx.Header("Content-Type", "text/tab-separated-values; charset=utf-8") + ctx.Header("Content-Disposition", "attachment; filename=\""+name+"\"") + ctx.Status(http.StatusOK) + if err := c.activityLogAdminService.Export(ctx, req, labels, ctx.Writer); err != nil { + log.Errorf("activity log export: %v", err) + } +} + +func dayOf(unix int64, fallback time.Time) string { + if unix <= 0 { + return fallback.Format("2006-01-02") + } + return time.Unix(unix, 0).Format("2006-01-02") +} diff --git a/internal/controller_admin/controller.go b/internal/controller_admin/controller.go index de0c7ec88..968fbffde 100644 --- a/internal/controller_admin/controller.go +++ b/internal/controller_admin/controller.go @@ -29,6 +29,7 @@ var ProviderSetController = wire.NewSet( NewRoleController, NewPluginController, NewBadgeController, + NewActivityLogController, NewAdminAPIKeyController, NewAIConversationAdminController, ) diff --git a/internal/entity/activity_log_entity.go b/internal/entity/activity_log_entity.go new file mode 100644 index 000000000..0d5070e68 --- /dev/null +++ b/internal/entity/activity_log_entity.go @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package entity + +import "time" + +// ActivityLog one row per user action for the admin "community activity log" +// (logins, page views, posts, votes, badges, review queue, reputation changes, ...). +type ActivityLog struct { + ID int64 `xorm:"not null pk autoincr BIGINT(20) id"` + CreatedAt time.Time `xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP index created_at"` + UserID string `xorm:"not null default 0 BIGINT(20) index user_id"` + Action string `xorm:"not null default '' VARCHAR(64) index action"` + ObjectType string `xorm:"not null default '' VARCHAR(32) object_type"` + ObjectID string `xorm:"not null default 0 BIGINT(20) index object_id"` + QuestionID string `xorm:"not null default 0 BIGINT(20) question_id"` + AnswerID string `xorm:"not null default 0 BIGINT(20) answer_id"` + TargetUserID string `xorm:"not null default 0 BIGINT(20) index target_user_id"` + RankDelta int `xorm:"not null default 0 INT(11) rank_delta"` + Detail string `xorm:"TEXT detail"` + IP string `xorm:"not null default '' VARCHAR(64) ip"` + UserAgent string `xorm:"not null default '' VARCHAR(512) user_agent"` + SourceRef string `xorm:"not null default '' VARCHAR(96) index source_ref"` +} + +// TableName activity_log table name +func (ActivityLog) TableName() string { + return "activity_log" +} diff --git a/internal/repo/activity/answer_repo.go b/internal/repo/activity/answer_repo.go index 96813f50c..d9fc15215 100644 --- a/internal/repo/activity/answer_repo.go +++ b/internal/repo/activity/answer_repo.go @@ -34,6 +34,7 @@ import ( "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/activity" "github.com/apache/answer/internal/service/activity_common" + "github.com/apache/answer/internal/service/activity_log" "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/rank" "github.com/apache/answer/pkg/converter" @@ -250,7 +251,7 @@ func (ar *AnswerActivityRepo) changeUserRank(ctx context.Context, session *xorm. if user == nil { continue } - if err = ar.userRankRepo.ChangeUserRank(ctx, session, + if err = ar.userRankRepo.ChangeUserRank(activity_log.WithRankContext(ctx, op.AnswerObjectID, act.ActivityType), session, act.ActivityUserID, user.Rank, act.Rank); err != nil { log.Error(err) return err @@ -270,7 +271,7 @@ func (ar *AnswerActivityRepo) rollbackUserRank(ctx context.Context, session *xor if user == nil { continue } - if err = ar.userRankRepo.ChangeUserRank(ctx, session, + if err = ar.userRankRepo.ChangeUserRank(activity_log.WithRankContext(ctx, act.ObjectID, act.ActivityType), session, act.UserID, user.Rank, -act.Rank); err != nil { log.Error(err) return err diff --git a/internal/repo/activity/review_repo.go b/internal/repo/activity/review_repo.go index c1f04b351..ea3eea0bc 100644 --- a/internal/repo/activity/review_repo.go +++ b/internal/repo/activity/review_repo.go @@ -32,6 +32,7 @@ import ( "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/service/activity" "github.com/apache/answer/internal/service/activity_common" + "github.com/apache/answer/internal/service/activity_log" "github.com/apache/answer/internal/service/config" "github.com/apache/answer/internal/service/rank" "github.com/segmentfault/pacman/errors" @@ -107,7 +108,7 @@ func (ar *ReviewActivityRepo) Review(ctx context.Context, act *schema.PassReview return nil, nil } - err = ar.userRankRepo.ChangeUserRank(ctx, session, addActivity.UserID, user.Rank, addActivity.Rank) + err = ar.userRankRepo.ChangeUserRank(activity_log.WithRankContext(ctx, addActivity.ObjectID, addActivity.ActivityType), session, addActivity.UserID, user.Rank, addActivity.Rank) if err != nil { return nil, err } diff --git a/internal/repo/activity/user_active_repo.go b/internal/repo/activity/user_active_repo.go index 68dfec7d7..f88fec76b 100644 --- a/internal/repo/activity/user_active_repo.go +++ b/internal/repo/activity/user_active_repo.go @@ -30,6 +30,7 @@ import ( "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/service/activity" "github.com/apache/answer/internal/service/activity_common" + "github.com/apache/answer/internal/service/activity_log" "github.com/apache/answer/internal/service/config" "github.com/apache/answer/internal/service/rank" "github.com/segmentfault/pacman/errors" @@ -102,7 +103,7 @@ func (ar *UserActiveActivityRepo) UserActive(ctx context.Context, userID string) return nil, nil } - err = ar.userRankRepo.ChangeUserRank(ctx, session, addActivity.UserID, user.Rank, addActivity.Rank) + err = ar.userRankRepo.ChangeUserRank(activity_log.WithRankContext(ctx, addActivity.ObjectID, addActivity.ActivityType), session, addActivity.UserID, user.Rank, addActivity.Rank) if err != nil { return nil, err } diff --git a/internal/repo/activity/vote_repo.go b/internal/repo/activity/vote_repo.go index 389ae18d8..f62daf897 100644 --- a/internal/repo/activity/vote_repo.go +++ b/internal/repo/activity/vote_repo.go @@ -42,6 +42,7 @@ import ( "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/activity_common" + "github.com/apache/answer/internal/service/activity_log" "github.com/segmentfault/pacman/errors" "xorm.io/xorm" ) @@ -276,7 +277,7 @@ func (vr *VoteRepo) changeUserRank(ctx context.Context, session *xorm.Session, if user == nil { continue } - if err = vr.userRankRepo.ChangeUserRank(ctx, session, + if err = vr.userRankRepo.ChangeUserRank(activity_log.WithRankContext(ctx, op.ObjectID, activity.ActivityType), session, activity.ActivityUserID, user.Rank, activity.Rank); err != nil { log.Error(err) return err @@ -296,7 +297,7 @@ func (vr *VoteRepo) rollbackUserRank(ctx context.Context, session *xorm.Session, if user == nil { continue } - if err = vr.userRankRepo.ChangeUserRank(ctx, session, + if err = vr.userRankRepo.ChangeUserRank(activity_log.WithRankContext(ctx, activity.ObjectID, activity.ActivityType), session, activity.UserID, user.Rank, -activity.Rank); err != nil { log.Error(err) return err diff --git a/internal/repo/activity_log/activity_log_repo.go b/internal/repo/activity_log/activity_log_repo.go new file mode 100644 index 000000000..bf5e3e4eb --- /dev/null +++ b/internal/repo/activity_log/activity_log_repo.go @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package activity_log + +import ( + "context" + "time" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/service/activity_log" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" + "xorm.io/builder" +) + +// activityLogRepo activity log repository +type activityLogRepo struct { + data *data.Data +} + +// NewActivityLogRepo new repository. The table is created on start-up (idempotent), so the +// fork does not have to hook into upstream's numbered migrations. +func NewActivityLogRepo(data *data.Data) activity_log.ActivityLogRepo { + if err := data.DB.Sync2(new(entity.ActivityLog)); err != nil { + log.Errorf("activity_log: sync table: %v", err) + } + return &activityLogRepo{data: data} +} + +// Add inserts one row +func (r *activityLogRepo) Add(ctx context.Context, entry *entity.ActivityLog) (err error) { + _, err = r.data.DB.Context(ctx).Insert(entry) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// ExistsSourceRef reports whether a row with the given source_ref exists (dedup for back-fill / page views) +func (r *activityLogRepo) ExistsSourceRef(ctx context.Context, sourceRef string) (bool, error) { + exist, err := r.data.DB.Context(ctx).Where("source_ref = ?", sourceRef).Exist(&entity.ActivityLog{}) + if err != nil { + return false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return exist, nil +} + +func (r *activityLogRepo) cond(q *activity_log.Query) builder.Cond { + cond := builder.NewCond() + if !q.From.IsZero() { + cond = cond.And(builder.Gte{"created_at": q.From}) + } + if !q.To.IsZero() { + cond = cond.And(builder.Lt{"created_at": q.To}) + } + if len(q.UserIDs) > 0 { + cond = cond.And(builder.Or(builder.In("user_id", q.UserIDs), builder.In("target_user_id", q.UserIDs))) + } + if len(q.Actions) > 0 { + cond = cond.And(builder.In("action", q.Actions)) + } + if q.Text != "" { + like := "%" + q.Text + "%" + textCond := builder.Or(builder.Like{"detail", like}, builder.Like{"action", like}, builder.Like{"ip", like}) + if len(q.TextUserIDs) > 0 { + textCond = textCond.Or(builder.In("user_id", q.TextUserIDs), builder.In("target_user_id", q.TextUserIDs)) + } + if q.TextObjectID != "" { + textCond = textCond.Or(builder.Eq{"object_id": q.TextObjectID}, builder.Eq{"question_id": q.TextObjectID}) + } + cond = cond.And(textCond) + } + return cond +} + +// Page returns one page of rows (newest first) and the total count +func (r *activityLogRepo) Page(ctx context.Context, q *activity_log.Query, page, pageSize int) (rows []*entity.ActivityLog, total int64, err error) { + rows = make([]*entity.ActivityLog, 0) + session := r.data.DB.Context(ctx).Where(r.cond(q)).Desc("created_at").Desc("id") + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 50 + } + total, err = session.Limit(pageSize, (page-1)*pageSize).FindAndCount(&rows) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +// Iterate walks matching rows newest first in batches (same order as Page); stops when fn returns false or limit is reached +func (r *activityLogRepo) Iterate(ctx context.Context, q *activity_log.Query, limit int, fn func(rows []*entity.ActivityLog) bool) error { + const batch = 1000 + sent := 0 + for sent < limit { + rows := make([]*entity.ActivityLog, 0, batch) + size := batch + if limit-sent < size { + size = limit - sent + } + err := r.data.DB.Context(ctx).Where(r.cond(q)).Desc("created_at").Desc("id").Limit(size, sent).Find(&rows) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if len(rows) == 0 { + return nil + } + if !fn(rows) { + return nil + } + sent += len(rows) + if len(rows) < size { + return nil + } + } + return nil +} + +// ActionCounts returns action → number of rows within the date range +func (r *activityLogRepo) ActionCounts(ctx context.Context, q *activity_log.Query) (map[string]int64, error) { + type row struct { + Action string `xorm:"action"` + Cnt int64 `xorm:"cnt"` + } + rows := make([]*row, 0) + dateOnly := &activity_log.Query{From: q.From, To: q.To} + err := r.data.DB.Context(ctx).Table(new(entity.ActivityLog)).Select("action, COUNT(*) AS cnt"). + Where(r.cond(dateOnly)).GroupBy("action").Find(&rows) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + out := make(map[string]int64, len(rows)) + for _, x := range rows { + out[x.Action] = x.Cnt + } + return out, nil +} + +// DeletePageViewsBefore removes page.view rows older than t (retention) +func (r *activityLogRepo) DeletePageViewsBefore(ctx context.Context, t time.Time) (int64, error) { + n, err := r.data.DB.Context(ctx).Where("action = ? AND created_at < ?", activity_log.ActionPageView, t).Delete(&entity.ActivityLog{}) + if err != nil { + return 0, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return n, nil +} + +// SearchUserIDs ids of users whose username / display name / e-mail contains text (for the free-text filter) +func (r *activityLogRepo) SearchUserIDs(ctx context.Context, text string, limit int) ([]string, error) { + like := "%" + text + "%" + users := make([]*entity.User, 0) + err := r.data.DB.Context(ctx).Cols("id"). + Where(builder.Or(builder.Like{"username", like}, builder.Like{"display_name", like}, builder.Like{"e_mail", like})). + Limit(limit).Find(&users) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + ids := make([]string, 0, len(users)) + for _, u := range users { + ids = append(ids, u.ID) + } + return ids, nil +} diff --git a/internal/repo/provider.go b/internal/repo/provider.go index 510a94aaa..3b28ac849 100644 --- a/internal/repo/provider.go +++ b/internal/repo/provider.go @@ -23,6 +23,7 @@ import ( "github.com/apache/answer/internal/base/data" "github.com/apache/answer/internal/repo/activity" "github.com/apache/answer/internal/repo/activity_common" + "github.com/apache/answer/internal/repo/activity_log" "github.com/apache/answer/internal/repo/ai_conversation" "github.com/apache/answer/internal/repo/answer" "github.com/apache/answer/internal/repo/api_key" @@ -110,6 +111,7 @@ var ProviderSetRepo = wire.NewSet( badge.NewEventRuleRepo, badge_group.NewBadgeGroupRepo, badge_award.NewBadgeAwardRepo, + activity_log.NewActivityLogRepo, file_record.NewFileRecordRepo, api_key.NewAPIKeyRepo, ai_conversation.NewAIConversationRepo, diff --git a/internal/repo/rank/user_rank_repo.go b/internal/repo/rank/user_rank_repo.go index 5e86f5929..16ee53ace 100644 --- a/internal/repo/rank/user_rank_repo.go +++ b/internal/repo/rank/user_rank_repo.go @@ -26,8 +26,10 @@ import ( "github.com/apache/answer/internal/base/pager" "github.com/apache/answer/internal/base/reason" "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/service/activity_log" "github.com/apache/answer/internal/service/config" "github.com/apache/answer/internal/service/rank" + "github.com/apache/answer/pkg/obj" "github.com/apache/answer/plugin" "github.com/jinzhu/now" "github.com/segmentfault/pacman/errors" @@ -38,18 +40,43 @@ import ( // UserRankRepo user rank repository type UserRankRepo struct { - data *data.Data - configService *config.ConfigService + data *data.Data + configService *config.ConfigService + activityLogService *activity_log.ActivityLogService } // NewUserRankRepo new repository -func NewUserRankRepo(data *data.Data, configService *config.ConfigService) rank.UserRankRepo { +func NewUserRankRepo(data *data.Data, configService *config.ConfigService, + activityLogService *activity_log.ActivityLogService) rank.UserRankRepo { return &UserRankRepo{ - data: data, - configService: configService, + data: data, + configService: configService, + activityLogService: activityLogService, } } +// logRankChange every reputation change ends up in the activity log +func (ur *UserRankRepo) logRankChange(ctx context.Context, userID string, delta int) { + if delta == 0 || ur.activityLogService == nil { + return + } + rc := activity_log.RankContextFrom(ctx) + detail := activity_log.Detail{} + // no DB reads here: this runs inside the caller's transaction (on SQLite a read on another + // connection would wait for the write lock forever). The key is resolved when the log is displayed. + if rc.ActivityType > 0 { + detail["activity_type"] = rc.ActivityType + } + entry := &entity.ActivityLog{UserID: activity_log.UserSystem, Action: activity_log.ActionReputationChange, + TargetUserID: userID, RankDelta: delta, ObjectID: rc.ObjectID} + if rc.ObjectID != "" { + if t, err := obj.GetObjectTypeStrByObjectID(rc.ObjectID); err == nil { + entry.ObjectType = t + } + } + ur.activityLogService.LogDetail(ctx, entry, detail) +} + func (ur *UserRankRepo) GetMaxDailyRank(ctx context.Context) (maxDailyRank int, err error) { maxDailyRank, err = ur.configService.GetIntValue(ctx, "daily_rank_limit") if err != nil { @@ -97,6 +124,7 @@ func (ur *UserRankRepo) ChangeUserRank( if err != nil { return err } + ur.logRankChange(ctx, userID, deltaRank) return nil } @@ -138,6 +166,7 @@ func (ur *UserRankRepo) TriggerUserRank(ctx context.Context, if err != nil { return false, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } + ur.logRankChange(activity_log.WithRankContext(ctx, activity_log.RankContextFrom(ctx).ObjectID, activityType), userID, deltaRank) return false, nil } diff --git a/internal/repo/user/user_repo.go b/internal/repo/user/user_repo.go index 1533cc5e8..9292abad0 100644 --- a/internal/repo/user/user_repo.go +++ b/internal/repo/user/user_repo.go @@ -24,13 +24,16 @@ import ( "strings" "time" + "github.com/apache/answer/internal/base/constant" "github.com/apache/answer/internal/base/data" "github.com/apache/answer/internal/base/reason" "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/activity_log" usercommon "github.com/apache/answer/internal/service/user_common" "github.com/apache/answer/pkg/converter" "github.com/apache/answer/plugin" + "github.com/gin-gonic/gin" "github.com/segmentfault/pacman/errors" "github.com/segmentfault/pacman/log" "xorm.io/builder" @@ -39,13 +42,15 @@ import ( // userRepo user repository type userRepo struct { - data *data.Data + data *data.Data + activityLogService *activity_log.ActivityLogService } // NewUserRepo new repository -func NewUserRepo(data *data.Data) usercommon.UserRepo { +func NewUserRepo(data *data.Data, activityLogService *activity_log.ActivityLogService) usercommon.UserRepo { return &userRepo{ - data: data, + data: data, + activityLogService: activityLogService, } } @@ -117,9 +122,30 @@ func (ur *userRepo) UpdateLastLoginDate(ctx context.Context, userID string) (err if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } + // every login path (password, Google, user center) updates the last login date → one log entry + ur.activityLogService.LogDetail(ctx, &entity.ActivityLog{UserID: userID, Action: activity_log.ActionUserLogin, + ObjectType: constant.UserObjectType, ObjectID: userID}, activity_log.Detail{"method": loginMethod(ctx)}) return nil } +// loginMethod guesses how the user logged in from the request path +func loginMethod(ctx context.Context) string { + ginCtx, ok := ctx.(*gin.Context) + if !ok || ginCtx == nil || ginCtx.Request == nil { + return "unknown" + } + path := ginCtx.Request.URL.Path + switch { + case strings.Contains(path, "/connector/"): + return strings.TrimPrefix(path[strings.LastIndex(path, "/"):], "/") // e.g. google + case strings.Contains(path, "/user-center/"): + return "user-center" + case strings.Contains(path, "/login/email"): + return "password" + } + return "other" +} + // UpdateEmailStatus update email status func (ur *userRepo) UpdateEmailStatus(ctx context.Context, userID string, emailStatus int) error { cond := &entity.User{MailStatus: emailStatus} diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 84b8b4e1c..f0622a3df 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -62,6 +62,8 @@ type AnswerAPIRouter struct { aiConversationController *controller.AIConversationController aiConversationAdminController *controller_admin.AIConversationAdminController mcpController *controller.MCPController + activityLogController *controller.ActivityLogController + adminActivityLogController *controller_admin.ActivityLogController } func NewAnswerAPIRouter( @@ -100,6 +102,8 @@ func NewAnswerAPIRouter( aiConversationController *controller.AIConversationController, aiConversationAdminController *controller_admin.AIConversationAdminController, mcpController *controller.MCPController, + activityLogController *controller.ActivityLogController, + adminActivityLogController *controller_admin.ActivityLogController, ) *AnswerAPIRouter { return &AnswerAPIRouter{ langController: langController, @@ -137,6 +141,8 @@ func NewAnswerAPIRouter( aiConversationController: aiConversationController, aiConversationAdminController: aiConversationAdminController, mcpController: mcpController, + activityLogController: activityLogController, + adminActivityLogController: adminActivityLogController, } } @@ -246,6 +252,8 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { // vote r.POST("/vote/up", a.voteController.VoteUp) + // page-view beacon (activity log) + r.POST("/activity-log/view", a.activityLogController.PageView) r.POST("/vote/down", a.voteController.VoteDown) // follow @@ -414,6 +422,10 @@ func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { // badge r.GET("/badges", a.adminBadgeController.GetBadgeList) + // community activity log + r.GET("/activity-log", a.adminActivityLogController.GetActivityLogPage) + r.GET("/activity-log/actions", a.adminActivityLogController.GetActivityLogActions) + r.GET("/activity-log/export", a.adminActivityLogController.ExportActivityLog) r.PUT("/badge/status", a.adminBadgeController.UpdateBadgeStatus) // api key diff --git a/internal/schema/activity_log_schema.go b/internal/schema/activity_log_schema.go new file mode 100644 index 000000000..bf6765eb4 --- /dev/null +++ b/internal/schema/activity_log_schema.go @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package schema + +// ActivityLogPageReq admin activity log filters +type ActivityLogPageReq struct { + Page int `validate:"omitempty,min=1" form:"page"` + PageSize int `validate:"omitempty,min=1,max=200" form:"page_size"` + // From / To unix seconds (To exclusive); both optional + From int64 `validate:"omitempty" form:"from"` + To int64 `validate:"omitempty" form:"to"` + // Username filters actor or target + Username string `validate:"omitempty,lte=64" form:"username"` + // Action comma separated list of action keys + Action string `validate:"omitempty,lte=1024" form:"action"` + // Q free text + Q string `validate:"omitempty,lte=200" form:"q"` +} + +// ActivityLogUser actor / target of an entry +type ActivityLogUser struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Avatar string `json:"avatar"` + Status string `json:"status"` +} + +// ActivityLogItem one grid row +type ActivityLogItem struct { + ID int64 `json:"id"` + CreatedAt int64 `json:"created_at"` + Action string `json:"action"` + User *ActivityLogUser `json:"user"` + Target *ActivityLogUser `json:"target,omitempty"` + ObjectType string `json:"object_type"` + ObjectID string `json:"object_id"` + QuestionID string `json:"question_id,omitempty"` + AnswerID string `json:"answer_id,omitempty"` + Title string `json:"title,omitempty"` + UrlTitle string `json:"url_title,omitempty"` + RankDelta int `json:"rank_delta"` + Detail map[string]any `json:"detail"` + IP string `json:"ip,omitempty"` +} + +// ActivityLogActionCount action key + number of entries in the range +type ActivityLogActionCount struct { + Action string `json:"action"` + Count int64 `json:"count"` +} + +// ActivityLogPageViewReq page view beacon from the UI +type ActivityLogPageViewReq struct { + Path string `validate:"required,lte=512" json:"path"` + Title string `validate:"omitempty,lte=300" json:"title"` + UserID string `json:"-"` +} diff --git a/internal/service/activity_log/activity_log_admin_service.go b/internal/service/activity_log/activity_log_admin_service.go new file mode 100644 index 000000000..456cea2ea --- /dev/null +++ b/internal/service/activity_log/activity_log_admin_service.go @@ -0,0 +1,305 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package activity_log + +import ( + "context" + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/apache/answer/internal/base/pager" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/config" + "github.com/apache/answer/internal/service/object_info" + usercommon "github.com/apache/answer/internal/service/user_common" + "github.com/apache/answer/pkg/htmltext" + "github.com/apache/answer/pkg/uid" + "github.com/segmentfault/pacman/log" +) + +// ExportLimit max rows in one TSV export +const ExportLimit = 100000 + +// ActivityLogAdminService reads the log for the admin UI. Its constructor also attaches the +// object / user lookups to the writer (see NewActivityLogService). +type ActivityLogAdminService struct { + logService *ActivityLogService + repo ActivityLogRepo + objectService *object_info.ObjService + userCommon *usercommon.UserCommon + configService *config.ConfigService +} + +// NewActivityLogAdminService new admin service +func NewActivityLogAdminService( + logService *ActivityLogService, + repo ActivityLogRepo, + objectService *object_info.ObjService, + userCommon *usercommon.UserCommon, + configService *config.ConfigService, +) *ActivityLogAdminService { + logService.Attach(objectService, userCommon) + return &ActivityLogAdminService{logService: logService, repo: repo, objectService: objectService, + userCommon: userCommon, configService: configService} +} + +// buildQuery turns the request into repo filters (usernames / free text resolved to user ids) +func (s *ActivityLogAdminService) buildQuery(ctx context.Context, req *schema.ActivityLogPageReq) (*Query, error) { + q := &Query{} + if req.From > 0 { + q.From = time.Unix(req.From, 0) + } + if req.To > 0 { + q.To = time.Unix(req.To, 0) + } + if req.Username != "" { + info, exist, err := s.userCommon.GetUserBasicInfoByUserName(ctx, strings.TrimSpace(req.Username)) + if err != nil { + return nil, err + } + if !exist { + q.UserIDs = []string{"-1"} // unknown user → no rows + } else { + q.UserIDs = []string{info.ID} + } + } + if req.Action != "" { + for _, a := range strings.Split(req.Action, ",") { + if a = strings.TrimSpace(a); a != "" { + q.Actions = append(q.Actions, a) + } + } + } + if text := strings.TrimSpace(req.Q); text != "" { + q.Text = text + ids, err := s.repo.SearchUserIDs(ctx, text, 50) + if err != nil { + return nil, err + } + q.TextUserIDs = ids + if id := uid.DeShortID(text); isNumeric(id) { + q.TextObjectID = id + } + } + return q, nil +} + +// Page one page of enriched rows +func (s *ActivityLogAdminService) Page(ctx context.Context, req *schema.ActivityLogPageReq) (*pager.PageModel, error) { + q, err := s.buildQuery(ctx, req) + if err != nil { + return nil, err + } + rows, total, err := s.repo.Page(ctx, q, req.Page, req.PageSize) + if err != nil { + return nil, err + } + items := s.decorate(ctx, rows) + return pager.NewPageModel(total, items), nil +} + +// Actions action keys with counts for the filter +func (s *ActivityLogAdminService) Actions(ctx context.Context, req *schema.ActivityLogPageReq) ([]*schema.ActivityLogActionCount, error) { + q, err := s.buildQuery(ctx, &schema.ActivityLogPageReq{From: req.From, To: req.To}) + if err != nil { + return nil, err + } + counts, err := s.repo.ActionCounts(ctx, q) + if err != nil { + return nil, err + } + out := make([]*schema.ActivityLogActionCount, 0, len(counts)) + for k, v := range counts { + out = append(out, &schema.ActivityLogActionCount{Action: k, Count: v}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Action < out[j].Action }) + return out, nil +} + +// PageView records a page view sent by the UI +func (s *ActivityLogAdminService) PageView(ctx context.Context, req *schema.ActivityLogPageViewReq) { + s.logService.LogPageView(ctx, req.UserID, req.Path, req.Title) +} + +// decorate resolves users and object titles for a batch of rows +func (s *ActivityLogAdminService) decorate(ctx context.Context, rows []*entity.ActivityLog) []*schema.ActivityLogItem { + userIDs := make([]string, 0, len(rows)*2) + seen := map[string]bool{} + for _, r := range rows { + for _, id := range []string{r.UserID, r.TargetUserID} { + if id != "" && id != "0" && !seen[id] { + seen[id] = true + userIDs = append(userIDs, id) + } + } + } + users := map[string]*schema.UserBasicInfo{} + if len(userIDs) > 0 { + if m, err := s.userCommon.BatchUserBasicInfoByID(ctx, userIDs); err == nil { + users = m + } else { + log.Error(err) + } + } + toUser := func(id string, actor bool) *schema.ActivityLogUser { + if id == "" || (id == UserSystem && !actor) { + return nil // no target + } + if id == UserSystem { + return &schema.ActivityLogUser{ID: UserSystem, Username: "system", DisplayName: "System"} + } + if u, ok := users[id]; ok && u != nil { + return &schema.ActivityLogUser{ID: u.ID, Username: u.Username, DisplayName: u.DisplayName, Avatar: u.Avatar, Status: u.Status} + } + return &schema.ActivityLogUser{ID: id, Username: "", DisplayName: "#" + id} + } + + items := make([]*schema.ActivityLogItem, 0, len(rows)) + for _, r := range rows { + detail := DecodeDetail(r.Detail) + item := &schema.ActivityLogItem{ + ID: r.ID, CreatedAt: r.CreatedAt.Unix(), Action: r.Action, User: toUser(r.UserID, true), Target: toUser(r.TargetUserID, false), + ObjectType: r.ObjectType, ObjectID: zeroToEmpty(r.ObjectID), QuestionID: zeroToEmpty(r.QuestionID), AnswerID: zeroToEmpty(r.AnswerID), + RankDelta: r.RankDelta, Detail: detail, IP: r.IP, + } + if t, ok := detail["title"].(string); ok { + item.Title = t + } + // reputation rows store the activity config id (written inside a transaction) → resolve the key here + if id, ok := detail["activity_type"].(float64); ok && id > 0 && s.configService != nil { + if cfg, err := s.configService.GetConfigByID(ctx, int(id)); err == nil && cfg != nil { + detail["activity"] = cfg.Key + } + delete(detail, "activity_type") + } + // reputation rows carry only the object id → look the title up + if item.Title == "" && item.ObjectID != "" && r.ObjectType != "page" && r.ObjectType != "user" && r.ObjectType != "badge_award" && s.objectService != nil { + if info, err := s.objectService.GetInfo(ctx, item.ObjectID); err == nil && info != nil { + item.Title = info.Title + if item.QuestionID == "" { + item.QuestionID = info.QuestionID + } + if item.AnswerID == "" { + item.AnswerID = info.AnswerID + } + } + } + if item.Title != "" { + item.UrlTitle = htmltext.UrlTitle(item.Title) + } + items = append(items, item) + } + return items +} + +// Export writes matching rows as TSV (UTF-8 with BOM so Excel opens it correctly) +func (s *ActivityLogAdminService) Export(ctx context.Context, req *schema.ActivityLogPageReq, labels map[string]string, w io.Writer) error { + q, err := s.buildQuery(ctx, req) + if err != nil { + return err + } + if _, err = io.WriteString(w, "\ufeff"); err != nil { + return err + } + header := []string{"czas", "login", "użytkownik", "akcja", "akcja_opis", "typ_obiektu", "id_obiektu", "tytuł", "cel_login", "cel_użytkownik", "punkty", "szczegóły", "ip"} + if _, err = io.WriteString(w, strings.Join(header, "\t")+"\r\n"); err != nil { + return err + } + var writeErr error + err = s.repo.Iterate(ctx, q, ExportLimit, func(rows []*entity.ActivityLog) bool { + for _, it := range s.decorate(ctx, rows) { + cols := []string{ + time.Unix(it.CreatedAt, 0).Format("2006-01-02 15:04:05"), + userCol(it.User, true), userCol(it.User, false), + it.Action, labels[it.Action], + it.ObjectType, it.ObjectID, it.Title, + userCol(it.Target, true), userCol(it.Target, false), + fmt.Sprintf("%d", it.RankDelta), + detailText(it.Detail), it.IP, + } + for i := range cols { + cols[i] = tsvClean(cols[i]) + } + if _, writeErr = io.WriteString(w, strings.Join(cols, "\t")+"\r\n"); writeErr != nil { + return false + } + } + return true + }) + if err != nil { + return err + } + return writeErr +} + +// zeroToEmpty "0" (no object) → "" so the UI does not build links to /questions/0 +func zeroToEmpty(id string) string { + if id == "0" { + return "" + } + return id +} + +func userCol(u *schema.ActivityLogUser, login bool) string { + if u == nil { + return "" + } + if login { + return u.Username + } + return u.DisplayName +} + +// detailText flattens the detail map to "k=v; k=v" (title is its own column) +func detailText(d map[string]any) string { + keys := make([]string, 0, len(d)) + for k := range d { + if k != "title" { + keys = append(keys, k) + } + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%v", k, d[k])) + } + return strings.Join(parts, "; ") +} + +func tsvClean(s string) string { + r := strings.NewReplacer("\t", " ", "\r", " ", "\n", " ") + return r.Replace(s) +} + +func isNumeric(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} diff --git a/internal/service/activity_log/activity_log_service.go b/internal/service/activity_log/activity_log_service.go new file mode 100644 index 000000000..c5391c90d --- /dev/null +++ b/internal/service/activity_log/activity_log_service.go @@ -0,0 +1,388 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package activity_log + +import ( + "context" + "encoding/json" + "strings" + "sync" + "time" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/base/queue" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/comment_common" + "github.com/apache/answer/internal/service/eventqueue" + "github.com/apache/answer/internal/service/object_info" + usercommon "github.com/apache/answer/internal/service/user_common" + "github.com/apache/answer/pkg/htmltext" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/log" +) + +// Action keys. Content actions follow the event naming (object.action); the rest are ours. +const ( + ActionUserLogin = "user.login" + ActionUserUpdate = "user.update" + ActionUserShare = "user.share" + ActionUserRole = "user.role" + ActionUserSuspend = "user.suspend" + ActionUserUnsuspend = "user.unsuspend" + ActionUserDelete = "user.delete" + ActionPageView = "page.view" + ActionBadgeAward = "badge.award" + ActionBadgeRevoke = "badge.revoke" + ActionReviewQueued = "review.queued" + ActionReviewApprove = "review.approve" + ActionReviewReject = "review.reject" + ActionReputationChange = "reputation.change" + ActionCommentReply = "comment.reply" +) + +// UserSystem is the actor of automatic actions (badge rules, reviewer bot) +const UserSystem = "0" + +// KnownActions every action key the log can contain (labels come from i18n ui.admin.activity_log.action.*) +var KnownActions = []string{ + ActionUserLogin, ActionUserUpdate, ActionUserShare, ActionUserRole, ActionUserSuspend, ActionUserUnsuspend, ActionUserDelete, + ActionPageView, ActionBadgeAward, ActionBadgeRevoke, ActionReviewQueued, ActionReviewApprove, ActionReviewReject, + ActionReputationChange, ActionCommentReply, + "question.create", "question.update", "question.delete", "question.vote_up", "question.vote_down", "question.vote_cancel", + "question.accept", "question.flag", "question.react", + "answer.create", "answer.update", "answer.delete", "answer.vote_up", "answer.vote_down", "answer.vote_cancel", + "answer.flag", "answer.react", + "comment.create", "comment.update", "comment.delete", "comment.vote_up", "comment.vote_down", "comment.vote_cancel", + "comment.flag", +} + +const ( + pageViewDedupWindow = 10 * time.Second + excerptLen = 160 +) + +// Query filters for listing / export +type Query struct { + From, To time.Time + UserIDs []string // actor or target + Actions []string + Text string // free text: matched against detail/action/ip … + TextUserIDs []string // … and against users whose name/username matched Text (resolved by the service) + TextObjectID string // … and against object/question id when Text looks like an id +} + +// ActivityLogRepo persistence +type ActivityLogRepo interface { + Add(ctx context.Context, entry *entity.ActivityLog) error + ExistsSourceRef(ctx context.Context, sourceRef string) (bool, error) + Page(ctx context.Context, q *Query, page, pageSize int) ([]*entity.ActivityLog, int64, error) + Iterate(ctx context.Context, q *Query, limit int, fn func(rows []*entity.ActivityLog) bool) error + ActionCounts(ctx context.Context, q *Query) (map[string]int64, error) + DeletePageViewsBefore(ctx context.Context, t time.Time) (int64, error) + // SearchUserIDs ids of users whose username / display name / e-mail contains text + SearchUserIDs(ctx context.Context, text string, limit int) ([]string, error) +} + +// Detail is the free-form part of an entry (stored as JSON) +type Detail map[string]any + +// ActivityLogService records what users do and serves the admin log +type ActivityLogService struct { + repo ActivityLogRepo + objectService *object_info.ObjService + userCommon *usercommon.UserCommon + commentRepo comment_common.CommentCommonRepo + writeQueue queue.Service[*entity.ActivityLog] + + pvMu sync.Mutex + pvLastSeen map[string]time.Time // userID+path → last page view (dedup) +} + +// NewActivityLogService new service; subscribes to the event queue next to the badge handler. +// Object / user lookups are attached later (Attach) because the repos that log into this +// service (user, rank) sit below object_info / user_common in the dependency graph. +func NewActivityLogService( + repo ActivityLogRepo, + commentRepo comment_common.CommentCommonRepo, + eventQueueService eventqueue.Service, +) *ActivityLogService { + s := &ActivityLogService{ + repo: repo, + commentRepo: commentRepo, + writeQueue: queue.New[*entity.ActivityLog]("activity_log", 1024), + pvLastSeen: make(map[string]time.Time), + } + s.writeQueue.RegisterHandler(func(ctx context.Context, entry *entity.ActivityLog) error { + return s.repo.Add(ctx, entry) + }) + eventQueueService.RegisterHandler(s.handleEvent) + return s +} + +// Attach wires the lookups used to enrich entries (called once by the admin service) +func (s *ActivityLogService) Attach(objectService *object_info.ObjService, userCommon *usercommon.UserCommon) { + s.objectService = objectService + s.userCommon = userCommon +} + +// Log records an entry asynchronously. IP and user agent are taken from the gin context when available. +func (s *ActivityLogService) Log(ctx context.Context, entry *entity.ActivityLog) { + if s == nil || entry == nil || entry.Action == "" { + return + } + if ginCtx, ok := ctx.(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { + if entry.IP == "" { + entry.IP = ginCtx.ClientIP() + } + if entry.UserAgent == "" { + entry.UserAgent = truncate(ginCtx.Request.UserAgent(), 512) + } + } + if entry.CreatedAt.IsZero() { + entry.CreatedAt = time.Now() + } + // bigint columns: PostgreSQL rejects "" (SQLite does not) + for _, f := range []*string{&entry.UserID, &entry.ObjectID, &entry.QuestionID, &entry.AnswerID, &entry.TargetUserID} { + if *f == "" { + *f = "0" + } + } + s.writeQueue.Send(context.Background(), entry) +} + +// LogDetail is Log with a detail map +func (s *ActivityLogService) LogDetail(ctx context.Context, entry *entity.ActivityLog, detail Detail) { + if s == nil || entry == nil { + return + } + entry.Detail = encodeDetail(detail) + s.Log(ctx, entry) +} + +// LogPageView records a page view, skipping repeats of the same path within a short window +func (s *ActivityLogService) LogPageView(ctx context.Context, userID, path, title string) (recorded bool) { + if userID == "" || path == "" { + return false + } + key := userID + "|" + path + now := time.Now() + s.pvMu.Lock() + last, seen := s.pvLastSeen[key] + if seen && now.Sub(last) < pageViewDedupWindow { + s.pvMu.Unlock() + return false + } + s.pvLastSeen[key] = now + if len(s.pvLastSeen) > 10000 { // keep the dedup map bounded + for k, t := range s.pvLastSeen { + if now.Sub(t) > pageViewDedupWindow { + delete(s.pvLastSeen, k) + } + } + } + s.pvMu.Unlock() + + entry := &entity.ActivityLog{UserID: userID, Action: ActionPageView, ObjectType: "page", ObjectID: "0"} + // link the view to the object when the path is a question/user/tag page + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) >= 2 { + switch parts[0] { + case "questions": + entry.ObjectType = constant.QuestionObjectType + entry.ObjectID = parts[1] + entry.QuestionID = parts[1] + if len(parts) >= 4 { + entry.AnswerID = parts[3] + } + case "users": + entry.ObjectType = constant.UserObjectType + case "tags": + entry.ObjectType = constant.TagObjectType + } + } + s.LogDetail(ctx, entry, Detail{"path": path, "title": truncate(title, 200)}) + return true +} + +// CleanupPageViews deletes page views older than the retention period +func (s *ActivityLogService) CleanupPageViews(ctx context.Context, days int) { + if days <= 0 { + return + } + n, err := s.repo.DeletePageViewsBefore(ctx, time.Now().AddDate(0, 0, -days)) + if err != nil { + log.Errorf("activity_log: cleanup page views: %v", err) + return + } + if n > 0 { + log.Infof("activity_log: removed %d page views older than %d days", n, days) + } +} + +// handleEvent maps an event from the shared event queue to a log entry +func (s *ActivityLogService) handleEvent(ctx context.Context, msg *schema.EventMsg) error { + if msg == nil { + return nil + } + entry := &entity.ActivityLog{UserID: msg.UserID, Action: string(msg.EventType), ObjectID: msg.TriggerObjectID, + QuestionID: msg.QuestionID, AnswerID: msg.AnswerID} + detail := Detail{} + if msg.EventType == constant.EventUserUpdate || msg.EventType == constant.EventUserShare { + entry.ObjectType = constant.UserObjectType + entry.ObjectID = msg.UserID + for k, v := range msg.ExtraInfo { + detail[k] = v + } + s.LogDetail(ctx, entry, detail) + return nil + } + + objectType, _, _ := strings.Cut(string(msg.EventType), ".") + entry.ObjectType = objectType + if entry.ObjectID == "" { + switch objectType { + case constant.QuestionObjectType: + entry.ObjectID = msg.QuestionID + case constant.AnswerObjectType: + entry.ObjectID = msg.AnswerID + case constant.CommentObjectType: + entry.ObjectID = msg.CommentID + } + } + // the object author is the "target" of votes, flags, reactions, accepts … + switch objectType { + case constant.QuestionObjectType: + entry.TargetUserID = msg.QuestionUserID + case constant.AnswerObjectType: + entry.TargetUserID = msg.AnswerUserID + case constant.CommentObjectType: + entry.TargetUserID = msg.CommentUserID + } + if entry.TargetUserID == entry.UserID { + entry.TargetUserID = "" + } + + if info := s.objectInfo(ctx, entry.ObjectID); info != nil { + if entry.QuestionID == "" { + entry.QuestionID = info.QuestionID + } + if entry.AnswerID == "" { + entry.AnswerID = info.AnswerID + } + if info.Title != "" { + detail["title"] = truncate(info.Title, 200) + } + if objectType != constant.QuestionObjectType || msg.EventType == constant.EventQuestionCreate || + msg.EventType == constant.EventQuestionUpdate { + if ex := excerpt(info.Content); ex != "" { + detail["excerpt"] = ex + } + } + if entry.TargetUserID == "" && info.ObjectCreatorUserID != entry.UserID { + entry.TargetUserID = info.ObjectCreatorUserID + } + } + + // votes: direction / cancel come from the extra info added by the vote service (added with the activity log) + if strings.HasSuffix(string(msg.EventType), ".vote") { + dir := msg.ExtraInfo["vote_direction"] + if dir == "" { + dir = "up" + } + if msg.ExtraInfo["vote_cancel"] == "1" { + entry.Action = objectType + ".vote_cancel" + detail["direction"] = dir + } else { + entry.Action = objectType + ".vote_" + dir + } + for _, k := range []string{"vote_up_amount", "vote_down_amount"} { + if v, ok := msg.ExtraInfo[k]; ok { + detail[k] = v + } + } + } + // a comment answering another comment is logged as a reply + if msg.EventType == constant.EventCommentCreate && msg.CommentID != "" && s.commentRepo != nil { + if c, exist, err := s.commentRepo.GetComment(ctx, msg.CommentID); err == nil && exist && c.GetReplyCommentID() != "" && c.GetReplyCommentID() != "0" { + entry.Action = ActionCommentReply + detail["reply_to"] = c.GetReplyCommentID() + // the person being answered is the target, not the post author + if parent, ok, err := s.commentRepo.GetComment(ctx, c.GetReplyCommentID()); err == nil && ok && parent.UserID != entry.UserID { + entry.TargetUserID = parent.UserID + } + } + } + for k, v := range msg.ExtraInfo { + if _, done := detail[k]; !done && k != "vote_direction" && k != "vote_cancel" { + detail[k] = v + } + } + s.LogDetail(ctx, entry, detail) + return nil +} + +func (s *ActivityLogService) objectInfo(ctx context.Context, objectID string) *schema.SimpleObjectInfo { + if s.objectService == nil || objectID == "" || objectID == "0" { + return nil + } + info, err := s.objectService.GetInfo(ctx, objectID) + if err != nil { + log.Debugf("activity_log: object %s: %v", objectID, err) + return nil + } + return info +} + +func encodeDetail(d Detail) string { + if len(d) == 0 { + return "" + } + b, err := json.Marshal(d) + if err != nil { + return "" + } + return string(b) +} + +// DecodeDetail parses the stored JSON detail (empty map on error) +func DecodeDetail(s string) Detail { + d := Detail{} + if s == "" { + return d + } + if err := json.Unmarshal([]byte(s), &d); err != nil { + return Detail{"raw": s} + } + return d +} + +func excerpt(content string) string { + text := strings.TrimSpace(htmltext.ClearText(content)) + return truncate(text, excerptLen) +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/internal/service/activity_log/activity_log_service_test.go b/internal/service/activity_log/activity_log_service_test.go new file mode 100644 index 000000000..b21137657 --- /dev/null +++ b/internal/service/activity_log/activity_log_service_test.go @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package activity_log + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/eventqueue" +) + +type fakeRepo struct{ added chan *entity.ActivityLog } + +func (f *fakeRepo) Add(_ context.Context, e *entity.ActivityLog) error { f.added <- e; return nil } +func (f *fakeRepo) ExistsSourceRef(context.Context, string) (bool, error) { return false, nil } +func (f *fakeRepo) Page(context.Context, *Query, int, int) ([]*entity.ActivityLog, int64, error) { + return nil, 0, nil +} +func (f *fakeRepo) Iterate(context.Context, *Query, int, func([]*entity.ActivityLog) bool) error { + return nil +} +func (f *fakeRepo) ActionCounts(context.Context, *Query) (map[string]int64, error) { return nil, nil } +func (f *fakeRepo) SearchUserIDs(context.Context, string, int) ([]string, error) { return nil, nil } +func (f *fakeRepo) DeletePageViewsBefore(context.Context, time.Time) (int64, error) { + return 0, nil +} + +type fakeCommentRepo struct{ reply string } + +func (f *fakeCommentRepo) GetComment(_ context.Context, id string) (*entity.Comment, bool, error) { + c := &entity.Comment{ID: id, UserID: "7"} + if id == "42" { // the comment being replied to + c.UserID = "3" + } else if f.reply != "" { + c.ReplyCommentID = sql.NullInt64{Int64: 42, Valid: true} + } + return c, true, nil +} +func (f *fakeCommentRepo) GetCommentWithoutStatus(ctx context.Context, id string) (*entity.Comment, bool, error) { + return f.GetComment(ctx, id) +} +func (f *fakeCommentRepo) GetCommentCount(context.Context) (int64, error) { return 0, nil } +func (f *fakeCommentRepo) RemoveAllUserComment(context.Context, string) error { return nil } +func (f *fakeCommentRepo) UpdateCommentStatus(context.Context, string, int) error { return nil } + +func newTestService(t *testing.T, reply string) (*ActivityLogService, *fakeRepo) { + t.Helper() + repo := &fakeRepo{added: make(chan *entity.ActivityLog, 10)} + s := NewActivityLogService(repo, &fakeCommentRepo{reply: reply}, eventqueue.NewService()) + return s, repo +} + +func next(t *testing.T, repo *fakeRepo) *entity.ActivityLog { + t.Helper() + select { + case e := <-repo.added: + return e + case <-time.After(2 * time.Second): + t.Fatal("no log entry written") + return nil + } +} + +func TestHandleEvent_VoteDirection(t *testing.T) { + s, repo := newTestService(t, "") + msg := schema.NewEvent(constant.EventAnswerVote, "7").TID("10020000000000055").AID("10020000000000055", "9") + msg.AddExtra("vote_direction", "down").AddExtra("vote_up_amount", "0").AddExtra("vote_down_amount", "1") + if err := s.handleEvent(context.Background(), msg); err != nil { + t.Fatal(err) + } + e := next(t, repo) + if e.Action != "answer.vote_down" || e.UserID != "7" || e.TargetUserID != "9" || e.ObjectID != "10020000000000055" || e.AnswerID != "10020000000000055" { + t.Fatalf("unexpected entry %+v", e) + } + d := DecodeDetail(e.Detail) + if d["vote_down_amount"] != "1" || d["vote_direction"] != nil { + t.Fatalf("unexpected detail %v", d) + } +} + +func TestHandleEvent_CommentReply(t *testing.T) { + s, repo := newTestService(t, "42") + msg := schema.NewEvent(constant.EventCommentCreate, "7").TID("10070000000000300").CID("10070000000000300", "7").QID("10010000000000100", "3") + if err := s.handleEvent(context.Background(), msg); err != nil { + t.Fatal(err) + } + e := next(t, repo) + if e.Action != ActionCommentReply || e.QuestionID != "10010000000000100" || e.TargetUserID != "3" { + t.Fatalf("unexpected entry %+v", e) + } + if DecodeDetail(e.Detail)["reply_to"] != "42" { + t.Fatalf("reply_to missing: %s", e.Detail) + } +} + +func TestHandleEvent_SelfTargetCleared(t *testing.T) { + s, repo := newTestService(t, "") + msg := schema.NewEvent(constant.EventQuestionCreate, "3").TID("10010000000000100").QID("10010000000000100", "3") + _ = s.handleEvent(context.Background(), msg) + e := next(t, repo) + if e.Action != "question.create" || e.TargetUserID != "0" || e.ObjectType != "question" { + t.Fatalf("unexpected entry %+v", e) + } +} + +func TestLogPageView_Dedup(t *testing.T) { + s, repo := newTestService(t, "") + if !s.LogPageView(context.Background(), "5", "/questions/100/slug", "T") { + t.Fatal("first view should be recorded") + } + if s.LogPageView(context.Background(), "5", "/questions/100/slug", "T") { + t.Fatal("repeat within window should be skipped") + } + e := next(t, repo) + if e.Action != ActionPageView || e.QuestionID != "100" || e.ObjectType != "question" { + t.Fatalf("unexpected entry %+v", e) + } +} diff --git a/internal/service/activity_log/rank_context.go b/internal/service/activity_log/rank_context.go new file mode 100644 index 000000000..75dd071b9 --- /dev/null +++ b/internal/service/activity_log/rank_context.go @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package activity_log + +import "context" + +type rankCtxKey struct{} + +// RankContext tells the reputation hook which object / activity caused a rank change. +// Callers that change reputation (vote, accept, review, first-login activities) wrap ctx with WithRankContext. +type RankContext struct { + ObjectID string + ActivityType int // config id of the activity (e.g. answer.voted_up), 0 when unknown +} + +// WithRankContext attaches object / activity info for the reputation log entry +func WithRankContext(ctx context.Context, objectID string, activityType int) context.Context { + return context.WithValue(ctx, rankCtxKey{}, RankContext{ObjectID: objectID, ActivityType: activityType}) +} + +// RankContextFrom reads what WithRankContext stored (zero value when absent) +func RankContextFrom(ctx context.Context) RankContext { + if v, ok := ctx.Value(rankCtxKey{}).(RankContext); ok { + return v + } + return RankContext{} +} diff --git a/internal/service/badge/badge_award_service.go b/internal/service/badge/badge_award_service.go index 0799b87c0..12784b2a8 100644 --- a/internal/service/badge/badge_award_service.go +++ b/internal/service/badge/badge_award_service.go @@ -28,6 +28,7 @@ import ( "github.com/apache/answer/internal/base/translator" "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/activity_log" "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/object_info" usercommon "github.com/apache/answer/internal/service/user_common" @@ -63,6 +64,7 @@ type BadgeAwardService struct { userCommon *usercommon.UserCommon objectInfoService *object_info.ObjService notificationQueueService noticequeue.Service + activityLogService *activity_log.ActivityLogService } func NewBadgeAwardService( @@ -71,6 +73,7 @@ func NewBadgeAwardService( userCommon *usercommon.UserCommon, objectInfoService *object_info.ObjService, notificationQueueService noticequeue.Service, + activityLogService *activity_log.ActivityLogService, ) *BadgeAwardService { return &BadgeAwardService{ badgeAwardRepo: badgeAwardRepo, @@ -78,6 +81,7 @@ func NewBadgeAwardService( userCommon: userCommon, objectInfoService: objectInfoService, notificationQueueService: notificationQueueService, + activityLogService: activityLogService, } } @@ -187,6 +191,9 @@ func (bs *BadgeAwardService) Award(ctx context.Context, badgeID string, userID s NotificationAction: constant.NotificationEarnedBadge, } bs.notificationQueueService.Send(ctx, msg) + bs.activityLogService.LogDetail(ctx, &entity.ActivityLog{UserID: activity_log.UserSystem, Action: activity_log.ActionBadgeAward, + ObjectType: constant.BadgeAwardObjectType, ObjectID: badgeAward.ID, TargetUserID: userID}, + activity_log.Detail{"badge": badgeData.Name, "badge_id": badgeData.ID, "award_key": awardKey}) return nil } diff --git a/internal/service/content/vote_service.go b/internal/service/content/vote_service.go index 1f74769f5..ce1e5d319 100644 --- a/internal/service/content/vote_service.go +++ b/internal/service/content/vote_service.go @@ -31,6 +31,7 @@ import ( "github.com/apache/answer/internal/base/pager" "github.com/apache/answer/internal/base/translator" "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/service/activity_log" "github.com/apache/answer/internal/service/activity_type" "github.com/apache/answer/internal/service/comment_common" "github.com/apache/answer/internal/service/config" @@ -56,13 +57,14 @@ type VoteRepo interface { // VoteService user service type VoteService struct { - voteRepo VoteRepo - configService *config.ConfigService - questionRepo questioncommon.QuestionRepo - answerRepo answercommon.AnswerRepo - commentCommonRepo comment_common.CommentCommonRepo - objectService *object_info.ObjService - eventQueueService eventqueue.Service + voteRepo VoteRepo + configService *config.ConfigService + questionRepo questioncommon.QuestionRepo + answerRepo answercommon.AnswerRepo + commentCommonRepo comment_common.CommentCommonRepo + objectService *object_info.ObjService + eventQueueService eventqueue.Service + activityLogService *activity_log.ActivityLogService } func NewVoteService( @@ -73,15 +75,17 @@ func NewVoteService( commentCommonRepo comment_common.CommentCommonRepo, objectService *object_info.ObjService, eventQueueService eventqueue.Service, + activityLogService *activity_log.ActivityLogService, ) *VoteService { return &VoteService{ - voteRepo: voteRepo, - configService: configService, - questionRepo: questionRepo, - answerRepo: answerRepo, - commentCommonRepo: commentCommonRepo, - objectService: objectService, - eventQueueService: eventQueueService, + voteRepo: voteRepo, + configService: configService, + questionRepo: questionRepo, + answerRepo: answerRepo, + commentCommonRepo: commentCommonRepo, + objectService: objectService, + eventQueueService: eventQueueService, + activityLogService: activityLogService, } } @@ -131,7 +135,9 @@ func (vs *VoteService) VoteUp(ctx context.Context, req *schema.VoteReq) (resp *s resp.Votes = resp.UpVotes - resp.DownVotes if !req.IsCancel { resp.VoteStatus = constant.ActVoteUp - vs.sendEvent(ctx, req, objectInfo, resp) + vs.sendEvent(ctx, req, objectInfo, resp, "up") + } else { + vs.logVoteCancel(ctx, req, objectInfo, "up") } return resp, nil } @@ -180,7 +186,9 @@ func (vs *VoteService) VoteDown(ctx context.Context, req *schema.VoteReq) (resp resp.Votes = resp.UpVotes - resp.DownVotes if !req.IsCancel { resp.VoteStatus = constant.ActVoteDown - vs.sendEvent(ctx, req, objectInfo, resp) + vs.sendEvent(ctx, req, objectInfo, resp, "down") + } else { + vs.logVoteCancel(ctx, req, objectInfo, "down") } return resp, nil } @@ -298,8 +306,20 @@ func (vs *VoteService) getActivities(ctx context.Context, op *schema.VoteOperati return activities } +// logVoteCancel cancelled votes are not events (badge rules must not re-run), only log entries +func (vs *VoteService) logVoteCancel(ctx context.Context, req *schema.VoteReq, objectInfo *schema.SimpleObjectInfo, direction string) { + target := objectInfo.ObjectCreatorUserID + if target == req.UserID { + target = "" + } + vs.activityLogService.LogDetail(ctx, &entity.ActivityLog{ + UserID: req.UserID, Action: objectInfo.ObjectType + ".vote_cancel", ObjectType: objectInfo.ObjectType, + ObjectID: objectInfo.ObjectID, QuestionID: objectInfo.QuestionID, AnswerID: objectInfo.AnswerID, TargetUserID: target, + }, activity_log.Detail{"direction": direction, "title": objectInfo.Title}) +} + func (vs *VoteService) sendEvent(ctx context.Context, - req *schema.VoteReq, objectInfo *schema.SimpleObjectInfo, resp *schema.VoteResp) { + req *schema.VoteReq, objectInfo *schema.SimpleObjectInfo, resp *schema.VoteResp, direction string) { var event *schema.EventMsg switch objectInfo.ObjectType { case constant.QuestionObjectType: @@ -316,5 +336,6 @@ func (vs *VoteService) sendEvent(ctx context.Context, } event.AddExtra("vote_up_amount", fmt.Sprintf("%d", resp.UpVotes)) event.AddExtra("vote_down_amount", fmt.Sprintf("%d", resp.DownVotes)) + event.AddExtra("vote_direction", direction) // for the activity log vs.eventQueueService.Send(ctx, event) } diff --git a/internal/service/provider.go b/internal/service/provider.go index d848272f7..fa8e1d76e 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -23,6 +23,7 @@ import ( "github.com/apache/answer/internal/service/action" "github.com/apache/answer/internal/service/activity" "github.com/apache/answer/internal/service/activity_common" + "github.com/apache/answer/internal/service/activity_log" "github.com/apache/answer/internal/service/activityqueue" "github.com/apache/answer/internal/service/ai_conversation" answercommon "github.com/apache/answer/internal/service/answer_common" @@ -128,6 +129,8 @@ var ProviderSetService = wire.NewSet( meta.NewMetaService, eventqueue.NewService, badge.NewBadgeService, + activity_log.NewActivityLogService, + activity_log.NewActivityLogAdminService, badge.NewBadgeEventService, badge.NewBadgeAwardService, badge.NewBadgeGroupService, diff --git a/internal/service/review/review_service.go b/internal/service/review/review_service.go index 054e2204f..2ce373164 100644 --- a/internal/service/review/review_service.go +++ b/internal/service/review/review_service.go @@ -21,12 +21,14 @@ package review import ( "context" + "strings" "github.com/apache/answer/internal/base/constant" "github.com/apache/answer/internal/base/pager" "github.com/apache/answer/internal/base/reason" "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/activity_log" answercommon "github.com/apache/answer/internal/service/answer_common" commentcommon "github.com/apache/answer/internal/service/comment_common" "github.com/apache/answer/internal/service/noticequeue" @@ -72,6 +74,7 @@ type ReviewService struct { siteInfoService siteinfo_common.SiteInfoCommonService commentCommonRepo commentcommon.CommentCommonRepo vectorSyncService vector_sync.Service + activityLogService *activity_log.ActivityLogService } // NewReviewService new review service @@ -90,6 +93,7 @@ func NewReviewService( siteInfoService siteinfo_common.SiteInfoCommonService, commentCommonRepo commentcommon.CommentCommonRepo, vectorSyncService vector_sync.Service, + activityLogService *activity_log.ActivityLogService, ) *ReviewService { return &ReviewService{ reviewRepo: reviewRepo, @@ -106,6 +110,7 @@ func NewReviewService( siteInfoService: siteInfoService, commentCommonRepo: commentCommonRepo, vectorSyncService: vectorSyncService, + activityLogService: activityLogService, } } @@ -238,10 +243,23 @@ func (cs *ReviewService) callPluginToReview(ctx context.Context, userID, objectI if err := cs.reviewRepo.AddReview(ctx, r); err != nil { log.Errorf("add review failed, err: %v", err) } + // the post went to the review queue ("quarantine") — actor is the bot, target the author + cs.activityLogService.LogDetail(ctx, &entity.ActivityLog{UserID: activity_log.UserSystem, Action: activity_log.ActionReviewQueued, + ObjectType: reviewContent.ObjectType, ObjectID: objectID, TargetUserID: userID}, + activity_log.Detail{"reason": r.Reason, "submitter": r.Submitter, "title": reviewContent.Title, + "excerpt": excerptOf(reviewContent.Content), "review_id": r.ID}) } return reviewStatus } +func excerptOf(content string) string { + text := strings.TrimSpace(htmltext.ClearText(content)) + if r := []rune(text); len(r) > 160 { + return string(r[:160]) + "…" + } + return text +} + // UpdateReview update review func (cs *ReviewService) UpdateReview(ctx context.Context, req *schema.UpdateReviewReq) (err error) { review, exist, err := cs.reviewRepo.GetReview(ctx, req.ReviewID) @@ -259,11 +277,23 @@ func (cs *ReviewService) UpdateReview(ctx context.Context, req *schema.UpdateRev return err } + action := activity_log.ActionReviewApprove if req.IsApprove() { err = cs.reviewRepo.UpdateReviewStatus(ctx, req.ReviewID, req.UserID, entity.ReviewStatusApproved) } else { + action = activity_log.ActionReviewReject err = cs.reviewRepo.UpdateReviewStatus(ctx, req.ReviewID, req.UserID, entity.ReviewStatusRejected) } + if err == nil { + entry := &entity.ActivityLog{UserID: req.UserID, Action: action, ObjectType: constant.ObjectTypeNumberMapping[review.ObjectType], + ObjectID: review.ObjectID, TargetUserID: review.UserID} + detail := activity_log.Detail{"reason": review.Reason, "review_id": review.ID} + if info, e := cs.objectInfoService.GetInfo(ctx, review.ObjectID); e == nil && info != nil { + entry.QuestionID, entry.AnswerID = info.QuestionID, info.AnswerID + detail["title"] = info.Title + } + cs.activityLogService.LogDetail(ctx, entry, detail) + } return } diff --git a/internal/service/user_admin/user_backyard.go b/internal/service/user_admin/user_backyard.go index 29e338046..1f488c842 100644 --- a/internal/service/user_admin/user_backyard.go +++ b/internal/service/user_admin/user_backyard.go @@ -45,6 +45,7 @@ import ( "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/activity" + "github.com/apache/answer/internal/service/activity_log" "github.com/apache/answer/internal/service/apikey" "github.com/apache/answer/internal/service/auth" "github.com/apache/answer/internal/service/role" @@ -89,6 +90,7 @@ type UserAdminService struct { pluginUserConfigRepo plugin_common.PluginUserConfigRepo badgeAwardRepo badge.BadgeAwardRepo apiKeyRepo apikey.APIKeyRepo + activityLogService *activity_log.ActivityLogService } // NewUserAdminService new user admin service @@ -108,6 +110,7 @@ func NewUserAdminService( pluginUserConfigRepo plugin_common.PluginUserConfigRepo, badgeAwardRepo badge.BadgeAwardRepo, apiKeyRepo apikey.APIKeyRepo, + activityLogService *activity_log.ActivityLogService, ) *UserAdminService { return &UserAdminService{ userRepo: userRepo, @@ -125,6 +128,7 @@ func NewUserAdminService( pluginUserConfigRepo: pluginUserConfigRepo, badgeAwardRepo: badgeAwardRepo, apiKeyRepo: apiKeyRepo, + activityLogService: activityLogService, } } @@ -166,6 +170,19 @@ func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.Up if err != nil { return err } + // activity log: suspend / unsuspend / delete by an admin + statusAction := map[bool]string{true: activity_log.ActionUserUnsuspend, false: ""}[req.IsNormal()] + switch { + case req.IsSuspended(): + statusAction = activity_log.ActionUserSuspend + case req.IsDeleted(): + statusAction = activity_log.ActionUserDelete + } + if statusAction != "" { + us.activityLogService.LogDetail(ctx, &entity.ActivityLog{UserID: req.LoginUserID, Action: statusAction, + ObjectType: constant.UserObjectType, ObjectID: req.UserID, TargetUserID: req.UserID}, + activity_log.Detail{"status": req.Status, "suspend_duration": req.SuspendDuration, "remove_all_content": req.RemoveAllContent}) + } if req.IsInactive() || req.IsSuspended() || req.IsDeleted() { if err := us.revokeUserAPIKeys(ctx, userInfo.ID); err != nil { return err @@ -236,6 +253,8 @@ func (us *UserAdminService) UpdateUserRole(ctx context.Context, req *schema.Upda if err != nil { return err } + us.activityLogService.LogDetail(ctx, &entity.ActivityLog{UserID: req.LoginUserID, Action: activity_log.ActionUserRole, + ObjectType: constant.UserObjectType, ObjectID: req.UserID, TargetUserID: req.UserID}, activity_log.Detail{"role_id": req.RoleID}) if err := us.revokeUserAPIKeys(ctx, req.UserID); err != nil { return err } diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index 862072817..0468c83ab 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -117,6 +117,7 @@ export const ADMIN_NAV_MENUS = [ children: [ { name: 'users', pathPrefix: 'users/' }, { name: 'badges' }, + { name: 'activity_log', path: 'activity-log' }, { name: 'rules', path: 'rules/privileges', pathPrefix: 'rules/' }, ], }, diff --git a/ui/src/pages/Admin/ActivityLog/index.tsx b/ui/src/pages/Admin/ActivityLog/index.tsx new file mode 100644 index 000000000..cb9ea939f --- /dev/null +++ b/ui/src/pages/Admin/ActivityLog/index.tsx @@ -0,0 +1,537 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FC, useEffect, useMemo, useState } from 'react'; +import { + Badge, + Button, + ButtonGroup, + Dropdown, + Form, + Stack, + Table, +} from 'react-bootstrap'; +import { Link, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import dayjs from 'dayjs'; +import qs from 'qs'; + +import { BaseUserCard, Empty, Pagination, Icon } from '@/components'; +import { toastStore } from '@/stores'; +import request from '@/utils/request'; +import { + ActivityLogItem, + ActivityLogParams, + exportActivityLog, + useQueryActivityLog, + useQueryActivityLogActions, +} from '@/services'; + +const PAGE_SIZE = 50; +const PRESETS = ['today', 'yesterday', '7d', '30d', 'custom'] as const; +type Preset = (typeof PRESETS)[number]; + +// which kind of thing an action is, for the colour of its badge +const actionKind = (action: string) => { + if (action === 'page.view') return 'view'; + if (action === 'user.login') return 'login'; + if (action.startsWith('badge.')) return 'badge'; + if (action.startsWith('review.') || action.endsWith('.flag')) + return 'moderation'; + if (action === 'reputation.change') return 'reputation'; + if ( + action.includes('.vote') || + action.endsWith('.react') || + action.endsWith('.accept') + ) + return 'vote'; + if (action.startsWith('user.')) return 'admin'; + return 'content'; +}; + +const kindVariant: Record = { + view: 'light', + login: 'secondary', + badge: 'warning', + moderation: 'danger', + reputation: 'success', + vote: 'info', + admin: 'dark', + content: 'primary', +}; + +// date range of a preset in the browser's time zone (from inclusive, to exclusive) +const presetRange = (preset: Preset): { from: number; to: number } | null => { + const start = dayjs().startOf('day'); + switch (preset) { + case 'today': + return { from: start.unix(), to: start.add(1, 'day').unix() }; + case 'yesterday': + return { from: start.subtract(1, 'day').unix(), to: start.unix() }; + case '7d': + return { + from: start.subtract(6, 'day').unix(), + to: start.add(1, 'day').unix(), + }; + case '30d': + return { + from: start.subtract(29, 'day').unix(), + to: start.add(1, 'day').unix(), + }; + default: + return null; + } +}; + +const Index: FC = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.activity_log', + }); + const { t: tGlobal } = useTranslation('translation'); + const [urlSearchParams, setUrlSearchParams] = useSearchParams(); + + const preset = (urlSearchParams.get('range') || 'today') as Preset; + const customFrom = urlSearchParams.get('from') || ''; + const customTo = urlSearchParams.get('to') || ''; + const username = urlSearchParams.get('username') || ''; + const action = urlSearchParams.get('action') || ''; + const q = urlSearchParams.get('q') || ''; + const page = Number(urlSearchParams.get('page') || 1); + + const [qInput, setQInput] = useState(q); + const [userInput, setUserInput] = useState(username); + const [userOptions, setUserOptions] = useState< + { username: string; display_name: string }[] + >([]); + const [exporting, setExporting] = useState(false); + + useEffect(() => setQInput(q), [q]); + useEffect(() => setUserInput(username), [username]); + + const range = useMemo(() => { + if (preset === 'custom') { + const from = customFrom + ? dayjs(customFrom).startOf('day').unix() + : undefined; + const to = customTo + ? dayjs(customTo).add(1, 'day').startOf('day').unix() + : undefined; + return { from, to }; + } + return presetRange(preset) || {}; + }, [preset, customFrom, customTo]); + + const params: ActivityLogParams = { + page, + page_size: PAGE_SIZE, + from: range.from, + to: range.to, + username, + action, + q, + }; + const { data, isLoading } = useQueryActivityLog(params); + const { data: actions } = useQueryActivityLogActions({ + from: range.from, + to: range.to, + }); + + const setParams = ( + patch: Record, + resetPage = true, + ) => { + const next: Record = {}; + urlSearchParams.forEach((v, k) => { + next[k] = v; + }); + Object.entries(patch).forEach(([k, v]) => { + if (v === undefined || v === '') delete next[k]; + else next[k] = v; + }); + if (resetPage) delete next.page; + setUrlSearchParams(next); + }; + + // username suggestions from the admin user list + useEffect(() => { + const term = userInput.trim(); + if (!term || term === username) { + setUserOptions([]); + return undefined; + } + const timer = setTimeout(() => { + request + .get( + `/answer/admin/api/users/page?${qs.stringify({ page: 1, page_size: 8, query: term })}`, + ) + .then((res: any) => setUserOptions(res?.list || [])) + .catch(() => setUserOptions([])); + }, 300); + return () => clearTimeout(timer); + }, [userInput, username]); + + const selectedActions = action ? action.split(',') : []; + const toggleAction = (key: string) => { + const set = new Set(selectedActions); + if (set.has(key)) set.delete(key); + else set.add(key); + setParams({ action: Array.from(set).join(',') }); + }; + + const actionLabel = (key: string) => + t(`action.${key.replace(/\./g, '_')}`, { defaultValue: key }); + + const handleExport = async () => { + setExporting(true); + try { + await exportActivityLog({ + ...params, + page: undefined, + page_size: undefined, + }); + } catch (e) { + toastStore + .getState() + .show({ msg: t('export_failed'), variant: 'danger' }); + } finally { + setExporting(false); + } + }; + + const objectLink = (item: ActivityLogItem) => { + const d = item.detail || {}; + const slug = item.url_title || ''; + switch (item.object_type) { + case 'question': + return item.question_id ? `/questions/${item.question_id}/${slug}` : ''; + case 'answer': + return item.question_id + ? `/questions/${item.question_id}/${slug}/${item.answer_id || item.object_id}` + : ''; + case 'comment': + return item.question_id + ? `/questions/${item.question_id}/${slug}${item.answer_id ? `/${item.answer_id}` : ''}?commentId=${item.object_id}` + : ''; + case 'page': + return typeof d.path === 'string' ? d.path : ''; + case 'user': + return item.target?.username + ? `/users/${item.target.username}` + : item.user?.username + ? `/users/${item.user.username}` + : ''; + case 'badge_award': + return item.target?.username + ? `/users/${item.target.username}/badges` + : ''; + default: + return ''; + } + }; + + const objectText = (item: ActivityLogItem) => { + const d = item.detail || {}; + if (item.title) return item.title; + if (item.object_type === 'page') + return `${d.title || ''} ${d.path ? `(${d.path})` : ''}`.trim(); + if (item.object_type === 'badge_award' && d.badge) + return tGlobal(d.badge, { defaultValue: d.badge }); + if (item.object_type === 'user') + return item.target?.display_name || item.user?.display_name || ''; + return item.object_id && item.object_id !== '0' ? `#${item.object_id}` : ''; + }; + + // the short facts shown under the object: reason, direction, method … + const detailText = (item: ActivityLogItem) => { + const d = item.detail || {}; + const parts: string[] = []; + if (d.excerpt) parts.push(String(d.excerpt)); + if (d.reason) parts.push(`${t('detail.reason')}: ${d.reason}`); + if (d.direction) parts.push(`${t('detail.direction')}: ${d.direction}`); + if (d.method) parts.push(`${t('detail.method')}: ${d.method}`); + if (d.activity) parts.push(String(d.activity)); + if (d.award_key && d.award_key !== 'admin' && d.award_key !== '0') + parts.push(String(d.award_key)); + if (d.role_id) parts.push(`${t('detail.role')}: ${d.role_id}`); + if (d.suspend_duration) parts.push(String(d.suspend_duration)); + if (d.reply_to) parts.push(`${t('detail.reply_to')} #${d.reply_to}`); + if (item.ip) parts.push(item.ip); + return parts.join(' · '); + }; + + return ( + <> +

{t('title')}

+
+
+ + {t('filter.range')} + +
+ + {PRESETS.map((p) => ( + + ))} + + {preset === 'custom' && ( + <> + setParams({ from: e.target.value })} + style={{ width: '10rem' }} + /> + + setParams({ to: e.target.value })} + style={{ width: '10rem' }} + /> + + )} +
+
+
+ + {t('filter.user')} + + setUserInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') setParams({ username: userInput.trim() }); + }} + onBlur={() => { + if (userInput.trim() !== username) + setParams({ username: userInput.trim() }); + }} + /> + + {userOptions.map((u) => ( + + ))} + +
+
+ + {t('filter.action')} + + + + {selectedActions.length + ? t('filter.action_selected', { count: selectedActions.length }) + : t('filter.action_all')} + + + {selectedActions.length > 0 && ( + setParams({ action: undefined })}> + {t('filter.action_clear')} + + )} + {(actions || []).map((a) => ( +
+ toggleAction(a.action)} + label={ + + {actionLabel(a.action)}{' '} + + ({a.count}) + + + } + /> +
+ ))} + {!actions?.length && ( +
+ {t('filter.action_none')} +
+ )} +
+
+
+
+ + {t('filter.search')} + + setQInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') setParams({ q: qInput.trim() }); + }} + onBlur={() => { + if (qInput.trim() !== q) setParams({ q: qInput.trim() }); + }} + /> +
+
+ +
+
+ +
+ {t('total', { count: Number(data?.count || 0) })} +
+ + + + + + + + + + + + + + {data?.list?.map((item) => { + const link = objectLink(item); + const text = objectText(item); + const facts = detailText(item); + return ( + + + + + + + + + ); + })} + +
{t('col.time')}{t('col.user')}{t('col.action')}{t('col.object')}{t('col.target')} + {t('col.points')} +
+ {dayjs.unix(item.created_at).format('YYYY-MM-DD HH:mm:ss')} + + {item.user?.id === '0' ? ( + + + {t('system')} + + ) : ( + + )} + + + {actionLabel(item.action)} + + + {link ? ( + + {text} + + ) : ( + {text} + )} + {facts && ( +
+ {facts} +
+ )} +
+ {item.target ? ( + + ) : null} + 0 ? 'text-success' : item.rank_delta < 0 ? 'text-danger' : 'text-secondary'}`}> + {item.rank_delta + ? item.rank_delta > 0 + ? `+${item.rank_delta}` + : item.rank_delta + : ''} +
+ {Number(data?.count) <= 0 && !isLoading && } +
+ +
+ + {t('hint')} + + + ); +}; + +export default Index; diff --git a/ui/src/pages/Layout/index.tsx b/ui/src/pages/Layout/index.tsx index 048ca812e..aced7c06c 100644 --- a/ui/src/pages/Layout/index.tsx +++ b/ui/src/pages/Layout/index.tsx @@ -30,6 +30,7 @@ import { errorCodeStore, siteSecurityStore, themeSettingStore, + loggedUserInfoStore, } from '@/stores'; import { Header, @@ -41,7 +42,7 @@ import { } from '@/components'; import { LoginToContinueModal, BadgeModal } from '@/components/Modal'; import { changeTheme, Storage, scrollToElementTop } from '@/utils'; -import { useQueryNotificationStatus } from '@/services'; +import { useQueryNotificationStatus, postPageView } from '@/services'; import { useExternalToast } from '@/hooks'; import { EXTERNAL_CONTENT_DISPLAY_MODE } from '@/common/constants'; @@ -131,6 +132,18 @@ const Layout: FC = () => { httpStatusReset(); }, [location]); + // tell the activity log which page the logged-in user opened + // (a moment later, so the page has set its title) + const loggedUserId = loggedUserInfoStore((state) => state.user?.id); + useEffect(() => { + if (!loggedUserId) return undefined; + const path = location.pathname; + const timer = setTimeout(() => { + postPageView({ path, title: document.title }).catch(() => undefined); + }, 800); + return () => clearTimeout(timer); + }, [location.pathname, loggedUserId]); + useEffect(() => { const systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); function handleSystemThemeChange(event) { diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index 8423fb7a3..a3677af62 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -397,6 +397,10 @@ const routes: RouteNode[] = [ path: 'users', page: 'pages/Admin/Users', }, + { + path: 'activity-log', + page: 'pages/Admin/ActivityLog', + }, { path: 'users/settings', page: 'pages/Admin/UsersSettings', diff --git a/ui/src/services/admin/activityLog.ts b/ui/src/services/admin/activityLog.ts new file mode 100644 index 000000000..0e031103d --- /dev/null +++ b/ui/src/services/admin/activityLog.ts @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import axios from 'axios'; +import qs from 'qs'; +import useSWR from 'swr'; + +import request from '@/utils/request'; +import Storage from '@/utils/storage'; +import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants'; +import type * as Type from '@/common/interface'; + +// community activity log +export interface ActivityLogUser { + id: string; + username: string; + display_name: string; + avatar?: string; + status?: string; +} + +export interface ActivityLogItem { + id: number; + created_at: number; + action: string; + user: ActivityLogUser; + target?: ActivityLogUser; + object_type: string; + object_id: string; + question_id?: string; + answer_id?: string; + title?: string; + url_title?: string; + rank_delta: number; + detail: Record; + ip?: string; +} + +export interface ActivityLogActionCount { + action: string; + count: number; +} + +export interface ActivityLogParams { + page?: number; + page_size?: number; + from?: number; + to?: number; + username?: string; + action?: string; + q?: string; +} + +const query = (params: ActivityLogParams) => + qs.stringify(params, { + skipNulls: true, + filter: (_, v) => (v === '' ? undefined : v), + }); + +export const useQueryActivityLog = (params: ActivityLogParams) => { + const apiUrl = `/answer/admin/api/activity-log?${query(params)}`; + const { data, error, mutate } = useSWR< + Type.ListResult, + Error + >(apiUrl, request.instance.get); + return { data, isLoading: !data && !error, error, mutate }; +}; + +export const useQueryActivityLogActions = (params: ActivityLogParams) => { + const apiUrl = `/answer/admin/api/activity-log/actions?${query(params)}`; + const { data, error } = useSWR( + apiUrl, + request.instance.get, + ); + return { data, isLoading: !data && !error, error }; +}; + +// the export needs the Authorization header and a raw (non-JSON) body, so bypass the +// JSON-unwrapping interceptor and hand the blob to the browser as a download +export const exportActivityLog = async (params: ActivityLogParams) => { + const res = await axios.get( + `/answer/admin/api/activity-log/export?${query(params)}`, + { + responseType: 'blob', + headers: { Authorization: Storage.get(LOGGED_TOKEN_STORAGE_KEY) || '' }, + }, + ); + const disposition = res?.headers?.['content-disposition'] || ''; + const match = /filename="?([^";]+)"?/.exec(disposition); + const filename = match ? match[1] : 'log-aktywnosci.tsv'; + const blob = res.data instanceof Blob ? res.data : new Blob([res.data]); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +}; + +export const postPageView = (params: { path: string; title?: string }) => { + return request.post('/answer/api/v1/activity-log/view', params, { + ignoreError: '50X', + } as any); +}; diff --git a/ui/src/services/admin/index.ts b/ui/src/services/admin/index.ts index 76d6ef90a..0f4e42b4f 100644 --- a/ui/src/services/admin/index.ts +++ b/ui/src/services/admin/index.ts @@ -25,6 +25,7 @@ export * from './users'; export * from './dashboard'; export * from './plugins'; export * from './badges'; +export * from './activityLog'; export * from './ai'; export * from './tags'; export * from './apikeys'; From 51f9273e43443cec69e30a93bfff41eb175946bb Mon Sep 17 00:00:00 2001 From: rafmaster7 Date: Wed, 16 Sep 2026 17:51:52 +0200 Subject: [PATCH 2/3] fix: escape spreadsheet formulas in the TSV export (CSV injection) Text columns of the export (titles, excerpts, reasons, user names) are user-influenced; a value starting with =, +, -, @, | or % would be evaluated as a formula by Excel / LibreOffice. Such cells now get a leading apostrophe; the numeric reputation delta is left as is. --- .../activity_log_admin_service.go | 30 ++++++++++++------- .../activity_log/activity_log_service_test.go | 19 ++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/internal/service/activity_log/activity_log_admin_service.go b/internal/service/activity_log/activity_log_admin_service.go index 456cea2ea..d5101c5ed 100644 --- a/internal/service/activity_log/activity_log_admin_service.go +++ b/internal/service/activity_log/activity_log_admin_service.go @@ -229,17 +229,15 @@ func (s *ActivityLogAdminService) Export(ctx context.Context, req *schema.Activi var writeErr error err = s.repo.Iterate(ctx, q, ExportLimit, func(rows []*entity.ActivityLog) bool { for _, it := range s.decorate(ctx, rows) { + // every column except the numeric delta is user-influenced text → escape for spreadsheets cols := []string{ time.Unix(it.CreatedAt, 0).Format("2006-01-02 15:04:05"), - userCol(it.User, true), userCol(it.User, false), - it.Action, labels[it.Action], - it.ObjectType, it.ObjectID, it.Title, - userCol(it.Target, true), userCol(it.Target, false), + tsvText(userCol(it.User, true)), tsvText(userCol(it.User, false)), + tsvText(it.Action), tsvText(labels[it.Action]), + tsvText(it.ObjectType), tsvText(it.ObjectID), tsvText(it.Title), + tsvText(userCol(it.Target, true)), tsvText(userCol(it.Target, false)), fmt.Sprintf("%d", it.RankDelta), - detailText(it.Detail), it.IP, - } - for i := range cols { - cols[i] = tsvClean(cols[i]) + tsvText(detailText(it.Detail)), tsvText(it.IP), } if _, writeErr = io.WriteString(w, strings.Join(cols, "\t")+"\r\n"); writeErr != nil { return false @@ -287,9 +285,19 @@ func detailText(d map[string]any) string { return strings.Join(parts, "; ") } -func tsvClean(s string) string { - r := strings.NewReplacer("\t", " ", "\r", " ", "\n", " ") - return r.Replace(s) +// tsvText makes a text cell safe for a TSV opened in a spreadsheet: no tabs / line breaks, and a +// leading apostrophe when the value would otherwise be interpreted as a formula (=, +, -, @, or a +// tab / CR that some spreadsheets strip before parsing) — CSV/TSV formula injection. +func tsvText(s string) string { + s = strings.NewReplacer("\t", " ", "\r", " ", "\n", " ").Replace(s) + if s == "" { + return s + } + switch s[0] { + case '=', '+', '-', '@', '|', '%': + return "'" + s + } + return s } func isNumeric(s string) bool { diff --git a/internal/service/activity_log/activity_log_service_test.go b/internal/service/activity_log/activity_log_service_test.go index b21137657..63750848a 100644 --- a/internal/service/activity_log/activity_log_service_test.go +++ b/internal/service/activity_log/activity_log_service_test.go @@ -138,3 +138,22 @@ func TestLogPageView_Dedup(t *testing.T) { t.Fatalf("unexpected entry %+v", e) } } + +func TestTsvText_FormulaInjection(t *testing.T) { + cases := map[string]string{ + "=1+1": "'=1+1", + "+SUM(A1)": "'+SUM(A1)", + "-2": "'-2", + "@cmd": "'@cmd", + "plain title": "plain title", + "tab\there\nline": "tab here line", + "": "", + "'already": "'already", + "Zamykanie miesiąca": "Zamykanie miesiąca", + } + for in, want := range cases { + if got := tsvText(in); got != want { + t.Errorf("tsvText(%q) = %q, want %q", in, got, want) + } + } +} From f2ffc80c26138246daf9b74f5a6889c41d5a2398 Mon Sep 17 00:00:00 2001 From: rafmaster7 Date: Wed, 16 Sep 2026 17:48:29 +0200 Subject: [PATCH 3/3] feat(dashboard): daily activity for the last 14 days, linking into the activity log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a card to the admin dashboard with one row per day (today first) and the number of questions, answers, comments, review-queue items, badge awards and page views. Every number opens the activity log in a new tab, already filtered to that day and those actions; the date opens the whole day. Days are bucketed in the admin's time zone (tz_offset). Backed by GET /answer/admin/api/activity-log/daily?days=&tz_offset= (aggregated in Go from created_at + action of the activity_log rows). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0181uZ2px83HmPo3HKSEzWQR Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0181uZ2px83HmPo3HKSEzWQR --- i18n/en_US.yaml | 11 ++ .../activity_log_controller.go | 18 +++ .../repo/activity_log/activity_log_repo.go | 10 ++ internal/router/answer_api_router.go | 1 + internal/schema/activity_log_schema.go | 23 ++++ .../activity_log_admin_service.go | 47 ++++++++ .../activity_log/activity_log_service.go | 2 + .../activity_log/activity_log_service_test.go | 5 +- .../components/DailyActivity/index.tsx | 106 ++++++++++++++++++ .../pages/Admin/Dashboard/components/index.ts | 3 +- ui/src/pages/Admin/Dashboard/index.tsx | 4 + ui/src/services/admin/activityLog.ts | 24 ++++ 12 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 ui/src/pages/Admin/Dashboard/components/DailyActivity/index.tsx diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index c512f2d9b..cbdc2291b 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -1865,6 +1865,17 @@ ui: dashboard: title: Dashboard welcome: Welcome to Admin! + daily_activity: + title: Activity in the last 14 days + day: Day + today: today + questions: Questions + answers: Answers + comments: Comments + reviews: To review + badges: Badges + views: Page views + open_log: Open in the activity log site_statistics: Site statistics questions: "Questions:" resolved: "Resolved:" diff --git a/internal/controller_admin/activity_log_controller.go b/internal/controller_admin/activity_log_controller.go index d1274690d..f60e61785 100644 --- a/internal/controller_admin/activity_log_controller.go +++ b/internal/controller_admin/activity_log_controller.go @@ -85,6 +85,24 @@ func (c *ActivityLogController) GetActivityLogActions(ctx *gin.Context) { handler.HandleResponse(ctx, err, resp) } +// GetActivityLogDaily daily counts for the dashboard +// @Summary activity log daily counts +// @Tags admin +// @Produce json +// @Security ApiKeyAuth +// @Param days query int false "days back incl. today (default 14)" +// @Param tz_offset query int false "browser UTC offset in minutes" +// @Success 200 {object} handler.RespBody{data=[]schema.ActivityLogDailyRow} +// @Router /answer/admin/api/activity-log/daily [get] +func (c *ActivityLogController) GetActivityLogDaily(ctx *gin.Context) { + req := &schema.ActivityLogDailyReq{} + if handler.BindAndCheck(ctx, req) { + return + } + resp, err := c.activityLogAdminService.Daily(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + // ExportActivityLog TSV export with the same filters as the page // @Summary activity log export (TSV) // @Tags admin diff --git a/internal/repo/activity_log/activity_log_repo.go b/internal/repo/activity_log/activity_log_repo.go index bf5e3e4eb..8bd13f67a 100644 --- a/internal/repo/activity_log/activity_log_repo.go +++ b/internal/repo/activity_log/activity_log_repo.go @@ -182,3 +182,13 @@ func (r *activityLogRepo) SearchUserIDs(ctx context.Context, text string, limit } return ids, nil } + +// ListActionsSince created_at + action only, for the dashboard's daily buckets +func (r *activityLogRepo) ListActionsSince(ctx context.Context, t time.Time) ([]*entity.ActivityLog, error) { + rows := make([]*entity.ActivityLog, 0) + err := r.data.DB.Context(ctx).Cols("created_at", "action").Where("created_at >= ?", t).Find(&rows) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return rows, nil +} diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index f0622a3df..a692c2e56 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -425,6 +425,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { // community activity log r.GET("/activity-log", a.adminActivityLogController.GetActivityLogPage) r.GET("/activity-log/actions", a.adminActivityLogController.GetActivityLogActions) + r.GET("/activity-log/daily", a.adminActivityLogController.GetActivityLogDaily) r.GET("/activity-log/export", a.adminActivityLogController.ExportActivityLog) r.PUT("/badge/status", a.adminBadgeController.UpdateBadgeStatus) diff --git a/internal/schema/activity_log_schema.go b/internal/schema/activity_log_schema.go index bf6765eb4..9920bcf13 100644 --- a/internal/schema/activity_log_schema.go +++ b/internal/schema/activity_log_schema.go @@ -73,3 +73,26 @@ type ActivityLogPageViewReq struct { Title string `validate:"omitempty,lte=300" json:"title"` UserID string `json:"-"` } + +// ActivityLogDailyReq daily activity counts for the dashboard +type ActivityLogDailyReq struct { + // Days how many days back (including today), default 14 + Days int `validate:"omitempty,min=1,max=90" form:"days"` + // TzOffset browser offset from UTC in minutes (Date.getTimezoneOffset() negated, e.g. 120 for CEST) + TzOffset int `validate:"omitempty,min=-840,max=840" form:"tz_offset"` +} + +// ActivityLogDailyRow counts of one local day +type ActivityLogDailyRow struct { + Date string `json:"date"` // YYYY-MM-DD in the browser's zone + From int64 `json:"from"` // unix start of that day + To int64 `json:"to"` // unix start of the next day + Questions int64 `json:"questions"` + Answers int64 `json:"answers"` + Comments int64 `json:"comments"` + Reviews int64 `json:"reviews"` + Badges int64 `json:"badges"` + Views int64 `json:"views"` + Logins int64 `json:"logins"` + Actions map[string]int64 `json:"actions"` // every action key → count (for future columns) +} diff --git a/internal/service/activity_log/activity_log_admin_service.go b/internal/service/activity_log/activity_log_admin_service.go index d5101c5ed..a1f9d3b38 100644 --- a/internal/service/activity_log/activity_log_admin_service.go +++ b/internal/service/activity_log/activity_log_admin_service.go @@ -142,6 +142,53 @@ func (s *ActivityLogAdminService) PageView(ctx context.Context, req *schema.Acti s.logService.LogPageView(ctx, req.UserID, req.Path, req.Title) } +// Daily counts per local day for the dashboard (today first) +func (s *ActivityLogAdminService) Daily(ctx context.Context, req *schema.ActivityLogDailyReq) ([]*schema.ActivityLogDailyRow, error) { + days := req.Days + if days <= 0 { + days = 14 + } + loc := time.FixedZone("browser", req.TzOffset*60) + today := time.Now().In(loc) + start := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -(days - 1)) + rows, err := s.repo.ListActionsSince(ctx, start) + if err != nil { + return nil, err + } + byDay := map[string]*schema.ActivityLogDailyRow{} + out := make([]*schema.ActivityLogDailyRow, 0, days) + for i := days - 1; i >= 0; i-- { + d := start.AddDate(0, 0, i) + row := &schema.ActivityLogDailyRow{Date: d.Format("2006-01-02"), From: d.Unix(), To: d.AddDate(0, 0, 1).Unix(), Actions: map[string]int64{}} + byDay[row.Date] = row + out = append(out, row) + } + for _, r := range rows { + row, ok := byDay[r.CreatedAt.In(loc).Format("2006-01-02")] + if !ok { + continue + } + row.Actions[r.Action]++ + switch r.Action { + case "question.create": + row.Questions++ + case "answer.create": + row.Answers++ + case "comment.create", ActionCommentReply: + row.Comments++ + case ActionReviewQueued: + row.Reviews++ + case ActionBadgeAward: + row.Badges++ + case ActionPageView: + row.Views++ + case ActionUserLogin: + row.Logins++ + } + } + return out, nil +} + // decorate resolves users and object titles for a batch of rows func (s *ActivityLogAdminService) decorate(ctx context.Context, rows []*entity.ActivityLog) []*schema.ActivityLogItem { userIDs := make([]string, 0, len(rows)*2) diff --git a/internal/service/activity_log/activity_log_service.go b/internal/service/activity_log/activity_log_service.go index c5391c90d..f7999e0e3 100644 --- a/internal/service/activity_log/activity_log_service.go +++ b/internal/service/activity_log/activity_log_service.go @@ -97,6 +97,8 @@ type ActivityLogRepo interface { Iterate(ctx context.Context, q *Query, limit int, fn func(rows []*entity.ActivityLog) bool) error ActionCounts(ctx context.Context, q *Query) (map[string]int64, error) DeletePageViewsBefore(ctx context.Context, t time.Time) (int64, error) + // ListActionsSince created_at + action of rows since t (for daily aggregation) + ListActionsSince(ctx context.Context, t time.Time) ([]*entity.ActivityLog, error) // SearchUserIDs ids of users whose username / display name / e-mail contains text SearchUserIDs(ctx context.Context, text string, limit int) ([]string, error) } diff --git a/internal/service/activity_log/activity_log_service_test.go b/internal/service/activity_log/activity_log_service_test.go index 63750848a..57ef1837c 100644 --- a/internal/service/activity_log/activity_log_service_test.go +++ b/internal/service/activity_log/activity_log_service_test.go @@ -42,7 +42,10 @@ func (f *fakeRepo) Iterate(context.Context, *Query, int, func([]*entity.Activity return nil } func (f *fakeRepo) ActionCounts(context.Context, *Query) (map[string]int64, error) { return nil, nil } -func (f *fakeRepo) SearchUserIDs(context.Context, string, int) ([]string, error) { return nil, nil } +func (f *fakeRepo) ListActionsSince(context.Context, time.Time) ([]*entity.ActivityLog, error) { + return nil, nil +} +func (f *fakeRepo) SearchUserIDs(context.Context, string, int) ([]string, error) { return nil, nil } func (f *fakeRepo) DeletePageViewsBefore(context.Context, time.Time) (int64, error) { return 0, nil } diff --git a/ui/src/pages/Admin/Dashboard/components/DailyActivity/index.tsx b/ui/src/pages/Admin/Dashboard/components/DailyActivity/index.tsx new file mode 100644 index 000000000..e16c3acfd --- /dev/null +++ b/ui/src/pages/Admin/Dashboard/components/DailyActivity/index.tsx @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FC } from 'react'; +import { Card, Table } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import dayjs from 'dayjs'; + +import { useQueryActivityLogDaily } from '@/services'; + +// What happened on each of the last 14 days; every number opens the +// activity log in a new tab, already filtered to that day and those actions +const COLUMNS: { key: string; actions: string }[] = [ + { key: 'questions', actions: 'question.create' }, + { key: 'answers', actions: 'answer.create' }, + { key: 'comments', actions: 'comment.create,comment.reply' }, + { key: 'reviews', actions: 'review.queued' }, + { key: 'badges', actions: 'badge.award' }, + { key: 'views', actions: 'page.view' }, +]; + +const DailyActivity: FC = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.dashboard.daily_activity', + }); + const { data } = useQueryActivityLogDaily(14); + const today = dayjs().format('YYYY-MM-DD'); + + const logLink = (date: string, actions: string) => + `/admin/activity-log?range=custom&from=${date}&to=${date}&action=${encodeURIComponent(actions)}`; + + return ( + + +
{t('title')}
+ + + + + {COLUMNS.map((c) => ( + + ))} + + + + {(data || []).map((row) => ( + + + {COLUMNS.map((c) => { + const n = Number(row[c.key] || 0); + return ( + + ); + })} + + ))} + +
{t('day')} + {t(c.key)} +
+ + {row.date} + {row.date === today ? ` (${t('today')})` : ''} + + + {n > 0 ? ( + + {n} + + ) : ( + 0 + )} +
+
+
+ ); +}; + +export default DailyActivity; diff --git a/ui/src/pages/Admin/Dashboard/components/index.ts b/ui/src/pages/Admin/Dashboard/components/index.ts index b8e68c23e..8f13e9d3e 100644 --- a/ui/src/pages/Admin/Dashboard/components/index.ts +++ b/ui/src/pages/Admin/Dashboard/components/index.ts @@ -21,5 +21,6 @@ import SystemInfo from './SystemInfo'; import Statistics from './Statistics'; import AnswerLinks from './AnswerLinks'; import HealthStatus from './HealthStatus'; +import DailyActivity from './DailyActivity'; -export { SystemInfo, Statistics, AnswerLinks, HealthStatus }; +export { SystemInfo, Statistics, AnswerLinks, HealthStatus, DailyActivity }; diff --git a/ui/src/pages/Admin/Dashboard/index.tsx b/ui/src/pages/Admin/Dashboard/index.tsx index 3740531eb..6e5579a5d 100644 --- a/ui/src/pages/Admin/Dashboard/index.tsx +++ b/ui/src/pages/Admin/Dashboard/index.tsx @@ -28,6 +28,7 @@ import { HealthStatus, Statistics, SystemInfo, + DailyActivity, } from './components'; const Dashboard: FC = () => { @@ -55,6 +56,9 @@ const Dashboard: FC = () => { + + + ); diff --git a/ui/src/services/admin/activityLog.ts b/ui/src/services/admin/activityLog.ts index 0e031103d..1ddfb91a5 100644 --- a/ui/src/services/admin/activityLog.ts +++ b/ui/src/services/admin/activityLog.ts @@ -91,6 +91,30 @@ export const useQueryActivityLogActions = (params: ActivityLogParams) => { return { data, isLoading: !data && !error, error }; }; +export interface ActivityLogDailyRow { + date: string; + from: number; + to: number; + questions: number; + answers: number; + comments: number; + reviews: number; + badges: number; + views: number; + logins: number; + actions: Record; +} + +export const useQueryActivityLogDaily = (days = 14) => { + const tzOffset = -new Date().getTimezoneOffset(); + const apiUrl = `/answer/admin/api/activity-log/daily?${qs.stringify({ days, tz_offset: tzOffset })}`; + const { data, error } = useSWR( + apiUrl, + request.instance.get, + ); + return { data, isLoading: !data && !error, error }; +}; + // the export needs the Authorization header and a raw (non-JSON) body, so bypass the // JSON-unwrapping interceptor and hand the blob to the browser as a download export const exportActivityLog = async (params: ActivityLogParams) => {