diff --git a/api4/reaction.go b/api4/reaction.go index ac19502d92..5827f14190 100644 --- a/api4/reaction.go +++ b/api4/reaction.go @@ -16,6 +16,9 @@ func (api *API) InitReaction() { api.BaseRoutes.Post.Handle("/reactions", api.APISessionRequired(getReactions)).Methods("GET") api.BaseRoutes.ReactionByNameForPostForUser.Handle("", api.APISessionRequired(deleteReaction)).Methods("DELETE") api.BaseRoutes.Posts.Handle("/ids/reactions", api.APISessionRequired(getBulkReactions)).Methods("POST") + + api.BaseRoutes.Team.Handle("/top/reactions", api.APISessionRequired(getTopReactionsForTeamSince)).Methods("GET") + api.BaseRoutes.Users.Handle("/me/top/reactions", api.APISessionRequired(getTopReactionsForUserSince)).Methods("GET") } func saveReaction(c *Context, w http.ResponseWriter, r *http.Request) { @@ -138,3 +141,85 @@ func getBulkReactions(c *Context, w http.ResponseWriter, r *http.Request) { } w.Write(js) } + +func getTopReactionsForTeamSince(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireTeamId().RequireTimeRange() + if c.Err != nil { + return + } + + team, err := c.App.GetTeam(c.Params.TeamId) + if err != nil { + c.Err = err + return + } + + if (!team.AllowOpenInvite || team.Type != model.TeamOpen) && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } + + topReactionList, err := c.App.GetTopReactionsForTeamSince(c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ + StartUnixMilli: c.Params.TimeRange, + Page: c.Params.Page, + PerPage: c.Params.PerPage, + }) + if err != nil { + c.Err = err + return + } + + js, jsonErr := json.Marshal(topReactionList) + if jsonErr != nil { + c.Err = model.NewAppError("getTopReactionsForTeamSince", "api.marshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(js) +} + +func getTopReactionsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireTimeRange() + if c.Err != nil { + return + } + + c.Params.TeamId = r.URL.Query().Get("team_id") + + // TeamId is an optional parameter + if c.Params.TeamId != "" { + if !model.IsValidId(c.Params.TeamId) { + c.SetInvalidURLParam("team_id") + return + } + + team, teamErr := c.App.GetTeam(c.Params.TeamId) + if teamErr != nil { + c.Err = teamErr + return + } + + if (!team.AllowOpenInvite || team.Type != model.TeamOpen) && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } + } + + topReactionList, err := c.App.GetTopReactionsForUserSince(c.AppContext.Session().UserId, c.Params.TeamId, &model.InsightsOpts{ + StartUnixMilli: c.Params.TimeRange, + Page: c.Params.Page, + PerPage: c.Params.PerPage, + }) + if err != nil { + c.Err = err + return + } + + js, jsonErr := json.Marshal(topReactionList) + if jsonErr != nil { + c.Err = model.NewAppError("getTopReactionsForUserSince", "api.marshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(js) +} diff --git a/api4/reaction_test.go b/api4/reaction_test.go index 86044498d8..e254eadb87 100644 --- a/api4/reaction_test.go +++ b/api4/reaction_test.go @@ -6,6 +6,7 @@ package api4 import ( "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -584,3 +585,367 @@ func TestGetBulkReactions(t *testing.T) { CheckUnauthorizedStatus(t, resp) }) } + +func TestGetTopReactionsForTeamSince(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.ConfigStore.SetReadOnlyFF(false) + defer th.ConfigStore.SetReadOnlyFF(true) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.InsightsEnabled = true }) + + client := th.Client + + userId := th.BasicUser.Id + user2Id := th.BasicUser2.Id + + post1 := &model.Post{UserId: userId, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} + post2 := &model.Post{UserId: userId, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} + post3 := &model.Post{UserId: userId, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} + post4 := &model.Post{UserId: user2Id, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} + post5 := &model.Post{UserId: user2Id, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} + + post1, _, _ = client.CreatePost(post1) + post2, _, _ = client.CreatePost(post2) + post3, _, _ = client.CreatePost(post3) + post4, _, _ = client.CreatePost(post4) + post5, _, _ = client.CreatePost(post5) + + userReactions := []*model.Reaction{ + { + UserId: userId, + PostId: post1.Id, + EmojiName: "happy", + }, + { + UserId: user2Id, + PostId: post1.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "sad", + }, + { + UserId: user2Id, + PostId: post1.Id, + EmojiName: "sad", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "joy", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "100", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "sad", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "joy", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "100", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "smile", + }, + { + UserId: user2Id, + PostId: post3.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "joy", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "100", + }, + { + UserId: userId, + PostId: post4.Id, + EmojiName: "joy", + }, + { + UserId: user2Id, + PostId: post4.Id, + EmojiName: "joy", + }, + { + UserId: userId, + PostId: post4.Id, + EmojiName: "100", + }, + { + UserId: userId, + PostId: post5.Id, + EmojiName: "100", + }, + { + UserId: user2Id, + PostId: post5.Id, + EmojiName: "100", + }, + { + UserId: user2Id, + PostId: post5.Id, + EmojiName: "+1", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "100", + CreateAt: model.GetMillisForTime(time.Now().Add(time.Hour * time.Duration(-25))), + }, + } + + for _, userReaction := range userReactions { + _, err := th.App.Srv().Store.Reaction().Save(userReaction) + require.NoError(t, err) + } + + teamId := th.BasicChannel.TeamId + + var expectedTopReactions [5]*model.TopReaction + expectedTopReactions[0] = &model.TopReaction{EmojiName: "100", Count: int64(6)} + expectedTopReactions[1] = &model.TopReaction{EmojiName: "joy", Count: int64(5)} + expectedTopReactions[2] = &model.TopReaction{EmojiName: "smile", Count: int64(4)} + expectedTopReactions[3] = &model.TopReaction{EmojiName: "sad", Count: int64(3)} + expectedTopReactions[4] = &model.TopReaction{EmojiName: "happy", Count: int64(2)} + + t.Run("get-top-reactions-for-team-since", func(t *testing.T) { + topReactions, _, err := client.GetTopReactionsForTeamSince(teamId, model.TimeRangeToday, 0, 5) + require.NoError(t, err) + reactions := topReactions.Items + + for i, reaction := range reactions { + assert.Equal(t, expectedTopReactions[i].EmojiName, reaction.EmojiName) + assert.Equal(t, expectedTopReactions[i].Count, reaction.Count) + } + + topReactions, _, err = client.GetTopReactionsForTeamSince(teamId, model.TimeRangeToday, 1, 5) + require.NoError(t, err) + reactions = topReactions.Items + + assert.Equal(t, "+1", reactions[0].EmojiName) + assert.Equal(t, int64(1), reactions[0].Count) + }) + + t.Run("get-top-reactions-for-team-since invalid team id", func(t *testing.T) { + _, resp, err := client.GetTopReactionsForTeamSince("12345", model.TimeRangeToday, 0, 5) + assert.Error(t, err) + CheckBadRequestStatus(t, resp) + + _, resp, err = client.GetTopReactionsForTeamSince(model.NewId(), model.TimeRangeToday, 0, 5) + assert.Error(t, err) + CheckNotFoundStatus(t, resp) + }) +} + +func TestGetTopReactionsForUserSince(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.ConfigStore.SetReadOnlyFF(false) + defer th.ConfigStore.SetReadOnlyFF(true) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.InsightsEnabled = true }) + + client := th.Client + + userId := th.BasicUser.Id + + post1 := &model.Post{UserId: userId, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} + post2 := &model.Post{UserId: userId, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} + post3 := &model.Post{UserId: userId, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} + post4 := &model.Post{UserId: userId, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} + post5 := &model.Post{UserId: userId, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} + post6 := &model.Post{UserId: userId, ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a"} + + post1, _, _ = client.CreatePost(post1) + post2, _, _ = client.CreatePost(post2) + post3, _, _ = client.CreatePost(post3) + post4, _, _ = client.CreatePost(post4) + post5, _, _ = client.CreatePost(post5) + post6, _, _ = client.CreatePost(post6) + + userReactions := []*model.Reaction{ + { + UserId: userId, + PostId: post1.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post4.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post5.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post6.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post4.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post5.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "+1", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "+1", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "+1", + }, + { + UserId: userId, + PostId: post4.Id, + EmojiName: "+1", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "heart", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "heart", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "heart", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "blush", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "blush", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "100", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "100", + CreateAt: model.GetMillisForTime(time.Now().Add(time.Hour * time.Duration(-25))), + }, + } + + for _, userReaction := range userReactions { + _, err := th.App.Srv().Store.Reaction().Save(userReaction) + require.NoError(t, err) + } + + teamId := th.BasicChannel.TeamId + + var expectedTopReactions [5]*model.TopReaction + expectedTopReactions[0] = &model.TopReaction{EmojiName: "happy", Count: int64(6)} + expectedTopReactions[1] = &model.TopReaction{EmojiName: "smile", Count: int64(5)} + expectedTopReactions[2] = &model.TopReaction{EmojiName: "+1", Count: int64(4)} + expectedTopReactions[3] = &model.TopReaction{EmojiName: "heart", Count: int64(3)} + expectedTopReactions[4] = &model.TopReaction{EmojiName: "blush", Count: int64(2)} + + t.Run("get-top-reactions-for-user-since", func(t *testing.T) { + topReactions, _, err := client.GetTopReactionsForUserSince(teamId, model.TimeRangeToday, 0, 5) + require.NoError(t, err) + reactions := topReactions.Items + + for i, reaction := range reactions { + assert.Equal(t, expectedTopReactions[i].EmojiName, reaction.EmojiName) + assert.Equal(t, expectedTopReactions[i].Count, reaction.Count) + } + + topReactions, _, err = client.GetTopReactionsForUserSince(teamId, model.TimeRangeToday, 1, 5) + require.NoError(t, err) + reactions = topReactions.Items + assert.Equal(t, "100", reactions[0].EmojiName) + assert.Equal(t, int64(1), reactions[0].Count) + }) + + t.Run("get-top-reactions-for-user-since invalid team id", func(t *testing.T) { + _, resp, err := client.GetTopReactionsForUserSince("invalid_team_id", model.TimeRangeToday, 0, 5) + assert.Error(t, err) + CheckBadRequestStatus(t, resp) + + _, resp, err = client.GetTopReactionsForUserSince(model.NewId(), model.TimeRangeToday, 0, 5) + assert.Error(t, err) + CheckNotFoundStatus(t, resp) + }) +} diff --git a/app/app_iface.go b/app/app_iface.go index 6816e9d4bd..dde3b80d1f 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -754,6 +754,8 @@ type AppIface interface { GetThreadMembershipsForUser(userID, teamID string) ([]*model.ThreadMembership, error) GetThreadsForUser(userID, teamID string, options model.GetUserThreadsOpts) (*model.Threads, *model.AppError) GetTokenById(token string) (*model.Token, *model.AppError) + GetTopReactionsForTeamSince(teamID string, userID string, opts *model.InsightsOpts) (*model.TopReactionList, *model.AppError) + GetTopReactionsForUserSince(userID string, teamID string, opts *model.InsightsOpts) (*model.TopReactionList, *model.AppError) GetUploadSession(uploadId string) (*model.UploadSession, *model.AppError) GetUploadSessionsForUser(userID string) ([]*model.UploadSession, *model.AppError) GetUser(userID string) (*model.User, *model.AppError) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 3e25ac8b1a..14f3de61e6 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -9596,6 +9596,50 @@ func (a *OpenTracingAppLayer) GetTokenById(token string) (*model.Token, *model.A return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetTopReactionsForTeamSince(teamID string, userID string, opts *model.InsightsOpts) (*model.TopReactionList, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetTopReactionsForTeamSince") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetTopReactionsForTeamSince(teamID, userID, opts) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetTopReactionsForUserSince(userID string, teamID string, opts *model.InsightsOpts) (*model.TopReactionList, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetTopReactionsForUserSince") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetTopReactionsForUserSince(userID, teamID, opts) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetTotalUsersStats(viewRestrictions *model.ViewUsersRestrictions) (*model.UsersStats, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetTotalUsersStats") diff --git a/app/reaction.go b/app/reaction.go index c6a80c61f6..6916fbbc8b 100644 --- a/app/reaction.go +++ b/app/reaction.go @@ -96,6 +96,30 @@ func populateEmptyReactions(postIDs []string, reactions map[string][]*model.Reac return reactions } +func (a *App) GetTopReactionsForTeamSince(teamID string, userID string, opts *model.InsightsOpts) (*model.TopReactionList, *model.AppError) { + if !a.Config().FeatureFlags.InsightsEnabled { + return nil, model.NewAppError("GetTopReactionsForTeamSince", "api.insights.feature_disabled", nil, "", http.StatusNotImplemented) + } + + topReactionList, err := a.Srv().Store.Reaction().GetTopForTeamSince(teamID, userID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage) + if err != nil { + return nil, model.NewAppError("GetTopReactionsForTeamSince", "app.reaction.get_top_for_team_since.app_error", nil, err.Error(), http.StatusInternalServerError) + } + return topReactionList, nil +} + +func (a *App) GetTopReactionsForUserSince(userID string, teamID string, opts *model.InsightsOpts) (*model.TopReactionList, *model.AppError) { + if !a.Config().FeatureFlags.InsightsEnabled { + return nil, model.NewAppError("GetTopReactionsForUserSince", "api.insights.feature_disabled", nil, "", http.StatusNotImplemented) + } + + topReactionList, err := a.Srv().Store.Reaction().GetTopForUserSince(userID, teamID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage) + if err != nil { + return nil, model.NewAppError("GetTopReactionsForUserSince", "app.reaction.get_top_for_user_since.app_error", nil, err.Error(), http.StatusInternalServerError) + } + return topReactionList, nil +} + func (a *App) DeleteReactionForPost(c *request.Context, reaction *model.Reaction) *model.AppError { post, err := a.GetSinglePost(reaction.PostId) if err != nil { diff --git a/app/reaction_test.go b/app/reaction_test.go index 3976175ddf..a9ad612a2e 100644 --- a/app/reaction_test.go +++ b/app/reaction_test.go @@ -5,6 +5,7 @@ package app import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -84,3 +85,345 @@ func TestSharedChannelSyncForReactionActions(t *testing.T) { assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[1]) }) } + +func TestGetTopReactionsForTeamSince(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.configStore.SetReadOnlyFF(false) + defer th.Server.configStore.SetReadOnlyFF(true) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.InsightsEnabled = true }) + + userId := th.BasicUser.Id + user2Id := th.BasicUser2.Id + + post1 := th.CreatePost(th.BasicChannel) + post2 := th.CreatePost(th.BasicChannel) + post3 := th.CreatePost(th.BasicChannel) + post4 := th.CreatePost(th.BasicChannel) + post5 := th.CreatePost(th.BasicChannel) + + userReactions := []*model.Reaction{ + { + UserId: userId, + PostId: post1.Id, + EmojiName: "happy", + }, + { + UserId: user2Id, + PostId: post1.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "sad", + }, + { + UserId: user2Id, + PostId: post1.Id, + EmojiName: "sad", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "joy", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "100", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "sad", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "joy", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "100", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "smile", + }, + { + UserId: user2Id, + PostId: post3.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "joy", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "100", + }, + { + UserId: userId, + PostId: post4.Id, + EmojiName: "joy", + }, + { + UserId: user2Id, + PostId: post4.Id, + EmojiName: "joy", + }, + { + UserId: userId, + PostId: post4.Id, + EmojiName: "100", + }, + { + UserId: userId, + PostId: post5.Id, + EmojiName: "100", + }, + { + UserId: user2Id, + PostId: post5.Id, + EmojiName: "100", + }, + { + UserId: user2Id, + PostId: post5.Id, + EmojiName: "+1", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "100", + CreateAt: model.GetMillisForTime(time.Now().Add(time.Hour * time.Duration(-25))), + }, + } + + for _, userReaction := range userReactions { + _, err := th.App.Srv().Store.Reaction().Save(userReaction) + require.NoError(t, err) + } + + teamId := th.BasicChannel.TeamId + + var expectedTopReactions [5]*model.TopReaction + expectedTopReactions[0] = &model.TopReaction{EmojiName: "100", Count: int64(6)} + expectedTopReactions[1] = &model.TopReaction{EmojiName: "joy", Count: int64(5)} + expectedTopReactions[2] = &model.TopReaction{EmojiName: "smile", Count: int64(4)} + expectedTopReactions[3] = &model.TopReaction{EmojiName: "sad", Count: int64(3)} + expectedTopReactions[4] = &model.TopReaction{EmojiName: "happy", Count: int64(2)} + + timeRange, _ := model.GetStartUnixMilliForTimeRange(model.TimeRangeToday) + + t.Run("get-top-reactions-for-team-since", func(t *testing.T) { + topReactions, err := th.App.GetTopReactionsForTeamSince(teamId, userId, &model.InsightsOpts{StartUnixMilli: timeRange, Page: 0, PerPage: 5}) + require.Nil(t, err) + reactions := topReactions.Items + + for i, reaction := range reactions { + assert.Equal(t, expectedTopReactions[i].EmojiName, reaction.EmojiName) + assert.Equal(t, expectedTopReactions[i].Count, reaction.Count) + } + topReactions, err = th.App.GetTopReactionsForTeamSince(teamId, userId, &model.InsightsOpts{StartUnixMilli: timeRange, Page: 1, PerPage: 5}) + require.Nil(t, err) + reactions = topReactions.Items + + assert.Equal(t, "+1", reactions[0].EmojiName) + assert.Equal(t, int64(1), reactions[0].Count) + }) + + t.Run("get-top-reactions-for-team-since feature flag", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.InsightsEnabled = false }) + _, err := th.App.GetTopReactionsForTeamSince(userId, teamId, &model.InsightsOpts{StartUnixMilli: timeRange, Page: 0, PerPage: 5}) + assert.NotNil(t, err) + }) +} + +func TestGetTopReactionsForUserSince(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.configStore.SetReadOnlyFF(false) + defer th.Server.configStore.SetReadOnlyFF(true) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.InsightsEnabled = true }) + + userId := th.BasicUser.Id + + post1 := th.CreatePost(th.BasicChannel) + post2 := th.CreatePost(th.BasicChannel) + post3 := th.CreatePost(th.BasicChannel) + post4 := th.CreatePost(th.BasicChannel) + post5 := th.CreatePost(th.BasicChannel) + post6 := th.CreatePost(th.BasicChannel) + + userReactions := []*model.Reaction{ + { + UserId: userId, + PostId: post1.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post4.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post5.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post6.Id, + EmojiName: "happy", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post4.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post5.Id, + EmojiName: "smile", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "+1", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "+1", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "+1", + }, + { + UserId: userId, + PostId: post4.Id, + EmojiName: "+1", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "heart", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "heart", + }, + { + UserId: userId, + PostId: post3.Id, + EmojiName: "heart", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "blush", + }, + { + UserId: userId, + PostId: post2.Id, + EmojiName: "blush", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "100", + }, + { + UserId: userId, + PostId: post1.Id, + EmojiName: "100", + CreateAt: model.GetMillisForTime(time.Now().Add(time.Hour * time.Duration(-25))), + }, + } + + for _, userReaction := range userReactions { + _, err := th.App.Srv().Store.Reaction().Save(userReaction) + require.NoError(t, err) + } + + teamId := th.BasicChannel.TeamId + + var expectedTopReactions [5]*model.TopReaction + expectedTopReactions[0] = &model.TopReaction{EmojiName: "happy", Count: int64(6)} + expectedTopReactions[1] = &model.TopReaction{EmojiName: "smile", Count: int64(5)} + expectedTopReactions[2] = &model.TopReaction{EmojiName: "+1", Count: int64(4)} + expectedTopReactions[3] = &model.TopReaction{EmojiName: "heart", Count: int64(3)} + expectedTopReactions[4] = &model.TopReaction{EmojiName: "blush", Count: int64(2)} + + timeRange, _ := model.GetStartUnixMilliForTimeRange(model.TimeRangeToday) + + t.Run("get-top-reactions-for-user-since", func(t *testing.T) { + topReactions, err := th.App.GetTopReactionsForUserSince(userId, teamId, &model.InsightsOpts{StartUnixMilli: timeRange, Page: 0, PerPage: 5}) + require.Nil(t, err) + reactions := topReactions.Items + + for i, reaction := range reactions { + assert.Equal(t, expectedTopReactions[i].EmojiName, reaction.EmojiName) + assert.Equal(t, expectedTopReactions[i].Count, reaction.Count) + } + + topReactions, err = th.App.GetTopReactionsForUserSince(userId, teamId, &model.InsightsOpts{StartUnixMilli: timeRange, Page: 1, PerPage: 5}) + require.Nil(t, err) + reactions = topReactions.Items + assert.Equal(t, "100", reactions[0].EmojiName) + assert.Equal(t, int64(1), reactions[0].Count) + }) + + t.Run("get-top-reactions-for-user-since feature flag", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.InsightsEnabled = false }) + _, err := th.App.GetTopReactionsForUserSince(userId, teamId, &model.InsightsOpts{StartUnixMilli: timeRange, Page: 0, PerPage: 5}) + assert.NotNil(t, err) + }) +} diff --git a/i18n/en.json b/i18n/en.json index e05d4b48eb..15015fedf9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1881,6 +1881,10 @@ "id": "api.incoming_webhook.invalid_username.app_error", "translation": "Invalid username." }, + { + "id": "api.insights.feature_disabled", + "translation": " " + }, { "id": "api.invalid_channel", "translation": "Channel listed in the request doesn't belong to the user" @@ -5887,6 +5891,14 @@ "id": "app.reaction.get_for_post.app_error", "translation": "Unable to get reactions for post." }, + { + "id": "app.reaction.get_top_for_team_since.app_error", + "translation": " " + }, + { + "id": "app.reaction.get_top_for_user_since.app_error", + "translation": " " + }, { "id": "app.reaction.save.save.app_error", "translation": "Unable to save reaction." @@ -8451,6 +8463,10 @@ "id": "model.incoming_hook.username.app_error", "translation": "Invalid username." }, + { + "id": "model.insights.time_range.app_error", + "translation": " " + }, { "id": "model.job.is_valid.create_at.app_error", "translation": "Create at must be a valid time." diff --git a/model/client4.go b/model/client4.go index bb0864e2a7..2f123b54a0 100644 --- a/model/client4.go +++ b/model/client4.go @@ -6496,6 +6496,39 @@ func (c *Client4) GetBulkReactions(postIds []string) (map[string][]*Reaction, *R return reactions, BuildResponse(r), nil } +func (c *Client4) GetTopReactionsForTeamSince(teamId string, timeRange string, page int, perPage int) (*TopReactionList, *Response, error) { + query := fmt.Sprintf("?time_range=%v&page=%v&per_page=%v", timeRange, page, perPage) + r, err := c.DoAPIGet(c.teamRoute(teamId)+"/top/reactions"+query, "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var topReactions *TopReactionList + if jsonErr := json.NewDecoder(r.Body).Decode(&topReactions); jsonErr != nil { + return nil, nil, NewAppError("GetTopReactionsForTeamSince", "api.unmarshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + } + return topReactions, BuildResponse(r), nil +} + +func (c *Client4) GetTopReactionsForUserSince(teamId string, timeRange string, page int, perPage int) (*TopReactionList, *Response, error) { + query := fmt.Sprintf("?time_range=%v&page=%v&per_page=%v", timeRange, page, perPage) + + if teamId != "" { + query += fmt.Sprintf("&team_id=%v", teamId) + } + + r, err := c.DoAPIGet(c.usersRoute()+"/me/top/reactions"+query, "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var topReactions *TopReactionList + if jsonErr := json.NewDecoder(r.Body).Decode(&topReactions); jsonErr != nil { + return nil, nil, NewAppError("GetTopReactionsForUserSince", "api.unmarshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + } + return topReactions, BuildResponse(r), nil +} + // Timezone Section // GetSupportedTimezone returns a page of supported timezones on the system. diff --git a/model/insights.go b/model/insights.go new file mode 100644 index 0000000000..e274bb83e3 --- /dev/null +++ b/model/insights.go @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "net/http" + "time" +) + +const ( + TimeRangeToday string = "today" + TimeRange7Day string = "7_day" + TimeRange28Day string = "28_day" +) + +type InsightsOpts struct { + StartUnixMilli int64 + Page int + PerPage int +} + +type InsightsListData struct { + HasNext bool `json:"has_next"` +} + +type InsightsData struct { + Rank int `json:"rank"` +} + +type TopReactionList struct { + InsightsListData + Items []*TopReaction `json:"items"` +} + +type TopReaction struct { + InsightsData + EmojiName string `json:"emoji_name"` + Count int64 `json:"count"` +} + +// GetStartUnixMilliForTimeRange gets the unix start time in milliseconds from the given time range. +// Time range can be one of: "1_day", "7_day", or "28_day". +func GetStartUnixMilliForTimeRange(timeRange string) (int64, *AppError) { + now := time.Now() + _, offset := now.Zone() + switch timeRange { + case TimeRangeToday: + return GetStartOfDayMillis(now, offset), nil + case TimeRange7Day: + return GetStartOfDayMillis(now.Add(time.Hour*time.Duration(-168)), offset), nil + case TimeRange28Day: + return GetStartOfDayMillis(now.Add(time.Hour*time.Duration(-672)), offset), nil + } + + return GetStartOfDayMillis(now, offset), NewAppError("Insights.IsValidRequest", "model.insights.time_range.app_error", nil, "", http.StatusBadRequest) +} + +// GetTopReactionListWithRankAndPagination adds a rank to each item in the given list of TopReaction and checks if there is +// another page that can be fetched based on the given limit and offset. The given list of TopReaction is assumed to be +// sorted by Count. Returns a TopReactionList. +func GetTopReactionListWithRankAndPagination(reactions []*TopReaction, limit int, offset int) *TopReactionList { + // Add pagination support + var hasNext bool + if (limit != 0) && (len(reactions) == limit+1) { + hasNext = true + reactions = reactions[:len(reactions)-1] + } + + // Assign rank to each reaction + for i, reaction := range reactions { + reaction.Rank = offset + i + 1 + } + + return &TopReactionList{InsightsListData: InsightsListData{HasNext: hasNext}, Items: reactions} +} diff --git a/model/insights_test.go b/model/insights_test.go new file mode 100644 index 0000000000..f1219adb5a --- /dev/null +++ b/model/insights_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetStartUnixMilliForTimeRang(t *testing.T) { + tc := [3]string{"today", "7_day", "28_day"} + + for _, timeRange := range tc { + t.Run(timeRange, func(t *testing.T) { + _, err := GetStartUnixMilliForTimeRange(timeRange) + assert.Nil(t, err) + }) + } + + invalidTimeRanges := [3]string{"", "1_day", "10_day"} + + for _, timeRange := range invalidTimeRanges { + t.Run(timeRange, func(t *testing.T) { + _, err := GetStartUnixMilliForTimeRange(timeRange) + assert.NotNil(t, err) + }) + } +} + +func TestGetTopReactionListWithRankAndPagination(t *testing.T) { + + reactions := []*TopReaction{ + {EmojiName: "smile", Count: 200}, + {EmojiName: "+1", Count: 190}, + {EmojiName: "100", Count: 100}, + {EmojiName: "-1", Count: 75}, + {EmojiName: "checkmark", Count: 50}, + {EmojiName: "mattermost", Count: 49}} + + hasNextTC := []struct { + Description string + Limit int + Offset int + Expected *TopReactionList + }{ + { + Description: "has one page", + Limit: len(reactions), + Offset: 0, + Expected: &TopReactionList{InsightsListData: InsightsListData{HasNext: false}, Items: reactions}, + }, + { + Description: "has more than one page", + Limit: len(reactions) - 1, + Offset: 0, + Expected: &TopReactionList{InsightsListData: InsightsListData{HasNext: true}, Items: reactions}, + }, + } + + for _, test := range hasNextTC { + t.Run(test.Description, func(t *testing.T) { + actual := GetTopReactionListWithRankAndPagination(reactions, test.Limit, test.Offset) + assert.Equal(t, test.Expected.HasNext, actual.HasNext) + }) + } + + t.Run("ranks for first and second page", func(t *testing.T) { + firstPage := GetTopReactionListWithRankAndPagination(reactions, 5, 0) + + for i, r := range firstPage.Items { + assert.Equal(t, i+1, r.Rank) + } + + secondPage := GetTopReactionListWithRankAndPagination(reactions, 5, 5) + for i, r := range secondPage.Items { + assert.Equal(t, i+1+5, r.Rank) + } + }) +} diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 28720afbba..8ee89e2778 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -6451,6 +6451,42 @@ func (s *OpenTracingLayerReactionStore) GetForPostSince(postId string, since int return result, err } +func (s *OpenTracingLayerReactionStore) GetTopForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopReactionList, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.GetTopForTeamSince") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ReactionStore.GetTopForTeamSince(teamID, userID, since, offset, limit) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerReactionStore) GetTopForUserSince(userID string, teamID string, since int64, offset int, limit int) (*model.TopReactionList, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.GetTopForUserSince") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ReactionStore.GetTopForUserSince(userID, teamID, since, offset, limit) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.PermanentDeleteBatch") diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 10f6f33bb9..293b66e471 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -7315,6 +7315,48 @@ func (s *RetryLayerReactionStore) GetForPostSince(postId string, since int64, ex } +func (s *RetryLayerReactionStore) GetTopForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopReactionList, error) { + + tries := 0 + for { + result, err := s.ReactionStore.GetTopForTeamSince(teamID, userID, since, offset, limit) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerReactionStore) GetTopForUserSince(userID string, teamID string, since int64, offset int, limit int) (*model.TopReactionList, error) { + + tries := 0 + for { + result, err := s.ReactionStore.GetTopForUserSince(userID, teamID, since, offset, limit) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { tries := 0 diff --git a/store/sqlstore/reaction_store.go b/store/sqlstore/reaction_store.go index 1c6746dd87..15b88678a2 100644 --- a/store/sqlstore/reaction_store.go +++ b/store/sqlstore/reaction_store.go @@ -228,6 +228,121 @@ func (s *SqlReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int return rowsAffected, nil } +// GetTopForTeamSince returns the instance counts of the following Reactions sets: +// a) those created by anyone in private channels in the given user's membership graph on the given team, and +// b) those created by anyone in public channels on the given team. +func (s *SqlReactionStore) GetTopForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopReactionList, error) { + var reactions []*model.TopReaction + + query := ` + SELECT + EmojiName, + sum(EmojiCount) AS Count + FROM (( + SELECT + EmojiName, + count(EmojiName) AS EmojiCount + FROM + ChannelMembers + INNER JOIN Channels ON ChannelMembers.ChannelId = Channels.Id + INNER JOIN Posts ON Channels.Id = Posts.ChannelId + INNER JOIN Reactions ON Posts.Id = Reactions.PostId + WHERE + Reactions.DeleteAt = 0 + AND ChannelMembers.UserId = ? + AND Channels.Type = 'P' + AND Channels.TeamId = ? + AND Reactions.CreateAt > ? + GROUP BY + Reactions.EmojiName) + UNION ALL ( + SELECT + EmojiName, + count(EmojiName) AS EmojiCount + FROM + Reactions + INNER JOIN Posts ON Reactions.PostId = Posts.Id + INNER JOIN Channels ON Posts.ChannelId = Channels.Id + WHERE + Reactions.DeleteAt = 0 + AND Channels.Type = 'O' + AND Channels.TeamId = ? + AND Reactions.CreateAt > ? + GROUP BY + Reactions.EmojiName)) AS A + GROUP BY + EmojiName + ORDER BY + Count DESC, + EmojiName ASC + LIMIT ? + OFFSET ?` + + if err := s.GetReplicaX().Select(&reactions, query, userID, teamID, since, teamID, since, limit+1, offset); err != nil { + return nil, errors.Wrap(err, "failed to get top Reactions") + } + + return model.GetTopReactionListWithRankAndPagination(reactions, limit, offset), nil +} + +// GetTopForUserSince returns the instance counts of the following Reactions sets: +// a) those created by the given user in any channel type on the given team (across the workspace if no team is given), and +// b) those created by the given user in DM or group channels. +func (s *SqlReactionStore) GetTopForUserSince(userID string, teamID string, since int64, offset int, limit int) (*model.TopReactionList, error) { + var reactions []*model.TopReaction + var args []interface{} + var query string + + if teamID != "" { + query = ` + SELECT + EmojiName, + count(EmojiName) AS Count + FROM + Reactions + INNER JOIN Posts ON Reactions.PostId = Posts.Id + INNER JOIN Channels ON Posts.ChannelId = Channels.Id + WHERE + Reactions.DeleteAt = 0 + AND Reactions.UserId = ? + AND (Channels.TeamId = ? OR Channels.Type = 'D' OR Channels.Type = 'G') + AND Reactions.CreateAt > ? + GROUP BY + EmojiName + ORDER BY + Count DESC, + EmojiName ASC + LIMIT ? + OFFSET ?` + args = []interface{}{userID, teamID, since, limit + 1, offset} + } else { + query = ` + SELECT + EmojiName, + count(EmojiName) AS Count + FROM + Reactions + WHERE + Reactions.DeleteAt = 0 + AND Reactions.UserId = ? + AND Reactions.CreateAt > ? + GROUP BY + Reactions.EmojiName + ORDER BY + Count DESC, + EmojiName ASC + LIMIT ? + OFFSET ?` + args = []interface{}{userID, since, limit + 1, offset} + } + + if err := s.GetReplicaX().Select(&reactions, query, args...); err != nil { + return nil, errors.Wrap(err, "failed to get top Reactions") + } + + return model.GetTopReactionListWithRankAndPagination(reactions, limit, offset), nil +} + func (s *SqlReactionStore) saveReactionAndUpdatePost(transaction *sqlxTxWrapper, reaction *model.Reaction) error { reaction.DeleteAt = 0 diff --git a/store/store.go b/store/store.go index 18ceb7f7bc..45f4ee766d 100644 --- a/store/store.go +++ b/store/store.go @@ -679,6 +679,8 @@ type ReactionStore interface { BulkGetForPosts(postIds []string) ([]*model.Reaction, error) DeleteOrphanedRows(limit int) (int64, error) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) + GetTopForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopReactionList, error) + GetTopForUserSince(userID string, teamID string, since int64, offset int, limit int) (*model.TopReactionList, error) } type JobStore interface { diff --git a/store/storetest/mocks/ReactionStore.go b/store/storetest/mocks/ReactionStore.go index 7deee52baa..1eb9a92d24 100644 --- a/store/storetest/mocks/ReactionStore.go +++ b/store/storetest/mocks/ReactionStore.go @@ -141,6 +141,52 @@ func (_m *ReactionStore) GetForPostSince(postId string, since int64, excludeRemo return r0, r1 } +// GetTopForTeamSince provides a mock function with given fields: teamID, userID, since, offset, limit +func (_m *ReactionStore) GetTopForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopReactionList, error) { + ret := _m.Called(teamID, userID, since, offset, limit) + + var r0 *model.TopReactionList + if rf, ok := ret.Get(0).(func(string, string, int64, int, int) *model.TopReactionList); ok { + r0 = rf(teamID, userID, since, offset, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.TopReactionList) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, int64, int, int) error); ok { + r1 = rf(teamID, userID, since, offset, limit) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetTopForUserSince provides a mock function with given fields: userID, teamID, since, offset, limit +func (_m *ReactionStore) GetTopForUserSince(userID string, teamID string, since int64, offset int, limit int) (*model.TopReactionList, error) { + ret := _m.Called(userID, teamID, since, offset, limit) + + var r0 *model.TopReactionList + if rf, ok := ret.Get(0).(func(string, string, int64, int, int) *model.TopReactionList); ok { + r0 = rf(userID, teamID, since, offset, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.TopReactionList) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, int64, int, int) error); ok { + r1 = rf(userID, teamID, since, offset, limit) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // PermanentDeleteBatch provides a mock function with given fields: endTime, limit func (_m *ReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { ret := _m.Called(endTime, limit) diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index e3fe87c9ca..ab505c93d9 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -5833,6 +5833,38 @@ func (s *TimerLayerReactionStore) GetForPostSince(postId string, since int64, ex return result, err } +func (s *TimerLayerReactionStore) GetTopForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopReactionList, error) { + start := timemodule.Now() + + result, err := s.ReactionStore.GetTopForTeamSince(teamID, userID, since, offset, limit) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ReactionStore.GetTopForTeamSince", success, elapsed) + } + return result, err +} + +func (s *TimerLayerReactionStore) GetTopForUserSince(userID string, teamID string, since int64, offset int, limit int) (*model.TopReactionList, error) { + start := timemodule.Now() + + result, err := s.ReactionStore.GetTopForUserSince(userID, teamID, since, offset, limit) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ReactionStore.GetTopForUserSince", success, elapsed) + } + return result, err +} + func (s *TimerLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { start := timemodule.Now() diff --git a/web/context.go b/web/context.go index 4adc4f95df..0295c2409b 100644 --- a/web/context.go +++ b/web/context.go @@ -374,6 +374,17 @@ func (c *Context) RequireTimestamp() *Context { return c } +func (c *Context) RequireTimeRange() *Context { + if c.Err != nil { + return c + } + + if c.Params.TimeRange == 0 { + c.SetInvalidURLParam("time_range") + } + return c +} + func (c *Context) RequireChannelId() *Context { if c.Err != nil { return c diff --git a/web/params.go b/web/params.go index c67b02e98d..81f8d8c066 100644 --- a/web/params.go +++ b/web/params.go @@ -31,6 +31,7 @@ type Params struct { TokenId string ThreadId string Timestamp int64 + TimeRange int64 ChannelId string PostId string PolicyId string @@ -254,6 +255,12 @@ func ParamsFromRequest(r *http.Request) *Params { params.Timestamp = val } + if val, err := model.GetStartUnixMilliForTimeRange(query.Get("time_range")); err != nil { + params.TimeRange = 0 + } else { + params.TimeRange = val + } + if val, err := strconv.ParseBool(query.Get("permanent")); err == nil { params.Permanent = val }