diff --git a/api4/api.go b/api4/api.go index 7285d79bde2..9f38eac19b3 100644 --- a/api4/api.go +++ b/api4/api.go @@ -134,6 +134,9 @@ type Routes struct { SharedChannels *mux.Router // 'api/v4/sharedchannels' Permissions *mux.Router // 'api/v4/permissions' + + InsightsForTeam *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}/top' + InsightsForUser *mux.Router // 'api/v4/users/me/top' } type API struct { @@ -255,6 +258,9 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Permissions = api.BaseRoutes.APIRoot.PathPrefix("/permissions").Subrouter() + api.BaseRoutes.InsightsForTeam = api.BaseRoutes.Team.PathPrefix("/top").Subrouter() + api.BaseRoutes.InsightsForUser = api.BaseRoutes.Users.PathPrefix("/me/top").Subrouter() + api.InitUser() api.InitBot() api.InitTeam() @@ -296,6 +302,7 @@ func Init(srv *app.Server) (*API, error) { api.InitSharedChannels() api.InitPermissions() api.InitExport() + api.InitInsights() if err := api.InitGraphQL(); err != nil { return nil, err } diff --git a/api4/insights.go b/api4/insights.go new file mode 100644 index 00000000000..a7f4828d14f --- /dev/null +++ b/api4/insights.go @@ -0,0 +1,204 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-server/v6/model" +) + +func (api *API) InitInsights() { + // Reactions + api.BaseRoutes.InsightsForTeam.Handle("/reactions", api.APISessionRequired(getTopReactionsForTeamSince)).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/reactions", api.APISessionRequired(getTopReactionsForUserSince)).Methods("GET") + + // Channels + api.BaseRoutes.InsightsForTeam.Handle("/channels", api.APISessionRequired(getTopChannelsForTeamSince)).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/channels", api.APISessionRequired(getTopChannelsForUserSince)).Methods("GET") +} + +// Top Reactions + +func getTopReactionsForTeamSince(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireTeamId() + if c.Err != nil { + return + } + + team, err := c.App.GetTeam(c.Params.TeamId) + if err != nil { + c.Err = err + return + } + + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } + + startTime, err := model.GetStartUnixMilliForTimeRange(c.Params.TimeRange) + if err != nil { + c.Err = err + return + } + + topReactionList, err := c.App.GetTopReactionsForTeamSince(c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ + StartUnixMilli: startTime, + 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.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 !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } + } + + startTime, err := model.GetStartUnixMilliForTimeRange(c.Params.TimeRange) + if err != nil { + c.Err = err + return + } + + topReactionList, err := c.App.GetTopReactionsForUserSince(c.AppContext.Session().UserId, c.Params.TeamId, &model.InsightsOpts{ + StartUnixMilli: startTime, + 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) +} + +// Top Channels + +func getTopChannelsForTeamSince(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireTeamId() + if c.Err != nil { + return + } + + team, err := c.App.GetTeam(c.Params.TeamId) + if err != nil { + c.Err = err + return + } + + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } + + startTime, err := model.GetStartUnixMilliForTimeRange(c.Params.TimeRange) + if err != nil { + c.Err = err + return + } + + topChannels, err := c.App.GetTopChannelsForTeamSince(c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ + StartUnixMilli: startTime, + Page: c.Params.Page, + PerPage: c.Params.PerPage, + }) + if err != nil { + c.Err = err + return + } + + js, jsonErr := json.Marshal(topChannels) + if jsonErr != nil { + c.Err = model.NewAppError("getTopChannelsForTeamSince", "api.marshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(js) +} + +func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + 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 !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } + } + + startTime, err := model.GetStartUnixMilliForTimeRange(c.Params.TimeRange) + if err != nil { + c.Err = err + return + } + + topChannels, err := c.App.GetTopChannelsForUserSince(c.AppContext.Session().UserId, c.Params.TeamId, &model.InsightsOpts{ + StartUnixMilli: startTime, + Page: c.Params.Page, + PerPage: c.Params.PerPage, + }) + + if err != nil { + c.Err = err + return + } + + js, jsonErr := json.Marshal(topChannels) + if jsonErr != nil { + c.Err = model.NewAppError("getTopChannelsForUserSince", "api.marshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(js) +} diff --git a/api4/insights_test.go b/api4/insights_test.go new file mode 100644 index 00000000000..b936cb8be74 --- /dev/null +++ b/api4/insights_test.go @@ -0,0 +1,600 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "testing" + "time" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Top Reactions + +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 exclude channels user is not member of", func(t *testing.T) { + excludedChannel := th.CreatePrivateChannel() + + for i := 0; i < 10; i++ { + post, _, err := client.CreatePost(&model.Post{UserId: userId, ChannelId: excludedChannel.Id, Message: "zz" + model.NewId() + "a"}) + require.NoError(t, err) + + reaction := &model.Reaction{ + UserId: userId, + PostId: post.Id, + EmojiName: "confused", + } + + _, err = th.App.Srv().Store.Reaction().Save(reaction) + require.NoError(t, err) + } + + th.RemoveUserFromChannel(th.BasicUser, excludedChannel) + + 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) + }) + + t.Run("get-top-reactions-for-team-since not a member of team", func(t *testing.T) { + th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) + _, resp, err := client.GetTopReactionsForTeamSince(teamId, model.TimeRangeToday, 0, 5) + assert.Error(t, err) + CheckForbiddenStatus(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) + }) + + t.Run("get-top-reactions-for-user-since not a member of team", func(t *testing.T) { + th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) + _, resp, err := client.GetTopReactionsForUserSince(teamId, model.TimeRangeToday, 0, 5) + assert.Error(t, err) + CheckForbiddenStatus(t, resp) + }) +} + +// Top Channels + +func TestGetTopChannelsForTeamSince(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 + + channel4 := th.CreatePublicChannel() + channel5 := th.CreatePrivateChannel() + channel6 := th.CreatePrivateChannel() + th.App.AddUserToChannel(th.BasicUser, channel4, false) + th.App.AddUserToChannel(th.BasicUser, channel5, false) + th.App.AddUserToChannel(th.BasicUser, channel6, false) + + channelIDs := [6]string{th.BasicChannel.Id, th.BasicChannel2.Id, th.BasicPrivateChannel.Id, channel4.Id, channel5.Id, channel6.Id} + + i := len(channelIDs) + for _, channelID := range channelIDs { + for j := i; j > 0; j-- { + _, _, err := client.CreatePost(&model.Post{UserId: userId, ChannelId: channelID, Message: "zz" + model.NewId() + "a"}) + require.NoError(t, err) + } + i-- + } + + teamId := th.BasicChannel.TeamId + + expectedTopChannels := []struct { + ID string + MessageCount int64 + }{ + {ID: th.BasicChannel.Id, MessageCount: 7}, + {ID: th.BasicChannel2.Id, MessageCount: 5}, + {ID: th.BasicPrivateChannel.Id, MessageCount: 4}, + {ID: channel4.Id, MessageCount: 3}, + {ID: channel5.Id, MessageCount: 2}, + } + + t.Run("get-top-channels-for-team-since", func(t *testing.T) { + topChannels, _, err := client.GetTopChannelsForTeamSince(teamId, model.TimeRangeToday, 0, 5) + require.NoError(t, err) + + for i, channel := range topChannels.Items { + assert.Equal(t, expectedTopChannels[i].ID, channel.ID) + assert.Equal(t, expectedTopChannels[i].MessageCount, channel.MessageCount) + } + + topChannels, _, err = client.GetTopChannelsForTeamSince(teamId, model.TimeRangeToday, 1, 5) + require.NoError(t, err) + assert.Equal(t, channel6.Id, topChannels.Items[0].ID) + assert.Equal(t, int64(1), topChannels.Items[0].MessageCount) + }) + + t.Run("get-top-channels-for-user-since exclude channels user is not member of", func(t *testing.T) { + excludedChannel := th.CreatePrivateChannel() + + for i := 0; i < 10; i++ { + _, _, err := client.CreatePost(&model.Post{UserId: userId, ChannelId: excludedChannel.Id, Message: "zz" + model.NewId() + "a"}) + require.NoError(t, err) + } + + th.RemoveUserFromChannel(th.BasicUser, excludedChannel) + + topChannels, _, err := client.GetTopChannelsForTeamSince(teamId, model.TimeRangeToday, 0, 5) + require.NoError(t, err) + + for i, channel := range topChannels.Items { + assert.Equal(t, expectedTopChannels[i].ID, channel.ID) + assert.Equal(t, expectedTopChannels[i].MessageCount, channel.MessageCount) + } + }) + + t.Run("get-top-channels-for-team-since invalid team id", func(t *testing.T) { + _, resp, err := client.GetTopChannelsForTeamSince("12345", model.TimeRangeToday, 0, 5) + assert.Error(t, err) + CheckBadRequestStatus(t, resp) + + _, resp, err = client.GetTopChannelsForTeamSince(model.NewId(), model.TimeRangeToday, 0, 5) + assert.Error(t, err) + CheckNotFoundStatus(t, resp) + }) + + t.Run("get-top-channels-for-team-since not a member of team", func(t *testing.T) { + th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) + _, resp, err := client.GetTopChannelsForTeamSince(teamId, model.TimeRangeToday, 0, 5) + assert.Error(t, err) + CheckForbiddenStatus(t, resp) + }) +} + +func TestGetTopChannelsForUserSince(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 + + channel4 := th.CreatePublicChannel() + channel5 := th.CreatePrivateChannel() + channel6 := th.CreatePrivateChannel() + th.App.AddUserToChannel(th.BasicUser, channel4, false) + th.App.AddUserToChannel(th.BasicUser, channel5, false) + th.App.AddUserToChannel(th.BasicUser, channel6, false) + + channelIDs := [6]string{th.BasicChannel.Id, th.BasicChannel2.Id, th.BasicPrivateChannel.Id, channel4.Id, channel5.Id, channel6.Id} + + i := len(channelIDs) + for _, channelID := range channelIDs { + for j := i; j > 0; j-- { + _, _, err := client.CreatePost(&model.Post{UserId: userId, ChannelId: channelID, Message: "zz" + model.NewId() + "a"}) + require.NoError(t, err) + } + i-- + } + + teamId := th.BasicChannel.TeamId + + expectedTopChannels := []struct { + ID string + MessageCount int64 + }{ + {ID: th.BasicChannel.Id, MessageCount: 6}, + {ID: th.BasicChannel2.Id, MessageCount: 5}, + {ID: th.BasicPrivateChannel.Id, MessageCount: 4}, + {ID: channel4.Id, MessageCount: 3}, + {ID: channel5.Id, MessageCount: 2}, + } + + t.Run("get-top-channels-for-user-since", func(t *testing.T) { + topChannels, _, err := client.GetTopChannelsForUserSince(teamId, model.TimeRangeToday, 0, 5) + require.NoError(t, err) + + for i, channel := range topChannels.Items { + assert.Equal(t, expectedTopChannels[i].ID, channel.ID) + assert.Equal(t, expectedTopChannels[i].MessageCount, channel.MessageCount) + } + + topChannels, _, err = client.GetTopChannelsForUserSince("", model.TimeRangeToday, 1, 5) + require.NoError(t, err) + assert.Equal(t, channel6.Id, topChannels.Items[0].ID) + assert.Equal(t, int64(1), topChannels.Items[0].MessageCount) + }) + + t.Run("get-top-channels-for-user-since invalid team id", func(t *testing.T) { + _, resp, err := client.GetTopChannelsForUserSince("12345", model.TimeRangeToday, 0, 5) + assert.Error(t, err) + CheckBadRequestStatus(t, resp) + + _, resp, err = client.GetTopChannelsForUserSince(model.NewId(), model.TimeRangeToday, 0, 5) + assert.Error(t, err) + CheckNotFoundStatus(t, resp) + }) + + t.Run("get-top-channels-for-user-since not a member of team", func(t *testing.T) { + th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) + _, resp, err := client.GetTopChannelsForUserSince(teamId, model.TimeRangeToday, 0, 5) + assert.Error(t, err) + CheckForbiddenStatus(t, resp) + }) +} diff --git a/api4/reaction.go b/api4/reaction.go index 5827f141901..ac19502d92b 100644 --- a/api4/reaction.go +++ b/api4/reaction.go @@ -16,9 +16,6 @@ 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) { @@ -141,85 +138,3 @@ 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 e254eadb87a..86044498d89 100644 --- a/api4/reaction_test.go +++ b/api4/reaction_test.go @@ -6,7 +6,6 @@ package api4 import ( "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -585,367 +584,3 @@ 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 b281cf9cedb..1f8dbb1780e 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) + GetTopChannelsForTeamSince(teamID, userID string, opts *model.InsightsOpts) (*model.TopChannelList, *model.AppError) + GetTopChannelsForUserSince(userID, teamID string, opts *model.InsightsOpts) (*model.TopChannelList, *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) diff --git a/app/channel.go b/app/channel.go index c4b6444e9ae..5394739f423 100644 --- a/app/channel.go +++ b/app/channel.go @@ -3439,3 +3439,27 @@ func (s *Server) getDirectChannel(userID, otherUserID string) (*model.Channel, * return channel, nil } + +func (a *App) GetTopChannelsForTeamSince(teamID, userID string, opts *model.InsightsOpts) (*model.TopChannelList, *model.AppError) { + if !a.Config().FeatureFlags.InsightsEnabled { + return nil, model.NewAppError("GetTopChannelsForTeamSince", "api.insights.feature_disabled", nil, "", http.StatusNotImplemented) + } + + topChannels, err := a.Srv().Store.Channel().GetTopChannelsForTeamSince(teamID, userID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage) + if err != nil { + return nil, model.NewAppError("GetTopChannelsForTeamSince", "app.channel.get_top_for_team_since.app_error", nil, err.Error(), http.StatusInternalServerError) + } + return topChannels, nil +} + +func (a *App) GetTopChannelsForUserSince(userID, teamID string, opts *model.InsightsOpts) (*model.TopChannelList, *model.AppError) { + if !a.Config().FeatureFlags.InsightsEnabled { + return nil, model.NewAppError("GetTopChannelsForUserSince", "api.insights.feature_disabled", nil, "", http.StatusNotImplemented) + } + + topChannels, err := a.Srv().Store.Channel().GetTopChannelsForUserSince(userID, teamID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage) + if err != nil { + return nil, model.NewAppError("GetTopChannelsForUserSince", "app.channel.get_top_for_user_since.app_error", nil, err.Error(), http.StatusInternalServerError) + } + return topChannels, nil +} diff --git a/app/channel_test.go b/app/channel_test.go index 78005010961..57f42625fcb 100644 --- a/app/channel_test.go +++ b/app/channel_test.go @@ -2446,3 +2446,119 @@ func TestMarkUnreadWithThreads(t *testing.T) { }) }) } + +func TestGetTopChannelsForTeamSince(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 }) + + channel2 := th.CreateChannel(th.BasicTeam) + channel3 := th.CreatePrivateChannel(th.BasicTeam) + channel4 := th.CreatePrivateChannel(th.BasicTeam) + channel5 := th.CreateChannel(th.BasicTeam) + channel6 := th.CreatePrivateChannel(th.BasicTeam) + th.AddUserToChannel(th.BasicUser, channel2) + th.AddUserToChannel(th.BasicUser, channel3) + th.AddUserToChannel(th.BasicUser, channel4) + th.AddUserToChannel(th.BasicUser, channel5) + th.AddUserToChannel(th.BasicUser, channel6) + + channels := [6]*model.Channel{th.BasicChannel, channel2, channel3, channel4, channel5, channel6} + + i := len(channels) + for _, channel := range channels { + for j := i; j > 0; j-- { + th.CreatePost(channel) + } + i-- + } + + expectedTopChannels := []struct { + ID string + MessageCount int64 + }{ + {ID: th.BasicChannel.Id, MessageCount: 7}, + {ID: channel2.Id, MessageCount: 5}, + {ID: channel3.Id, MessageCount: 4}, + {ID: channel4.Id, MessageCount: 3}, + {ID: channel5.Id, MessageCount: 2}, + } + + timeRange, _ := model.GetStartUnixMilliForTimeRange(model.TimeRangeToday) + + t.Run("get-top-channels-for-team-since", func(t *testing.T) { + topChannels, err := th.App.GetTopChannelsForTeamSince(th.BasicChannel.TeamId, th.BasicUser.Id, &model.InsightsOpts{StartUnixMilli: timeRange, Page: 0, PerPage: 5}) + require.Nil(t, err) + + for i, channel := range topChannels.Items { + assert.Equal(t, expectedTopChannels[i].ID, channel.ID) + assert.Equal(t, expectedTopChannels[i].MessageCount, channel.MessageCount) + } + + topChannels, err = th.App.GetTopChannelsForTeamSince(th.BasicChannel.TeamId, th.BasicUser.Id, &model.InsightsOpts{StartUnixMilli: timeRange, Page: 1, PerPage: 5}) + require.Nil(t, err) + assert.Equal(t, channel6.Id, topChannels.Items[0].ID) + assert.Equal(t, int64(1), topChannels.Items[0].MessageCount) + }) +} + +func TestGetTopChannelsForUserSince(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 }) + + channel2 := th.CreateChannel(th.BasicTeam) + channel3 := th.CreatePrivateChannel(th.BasicTeam) + channel4 := th.CreatePrivateChannel(th.BasicTeam) + channel5 := th.CreateChannel(th.BasicTeam) + channel6 := th.CreatePrivateChannel(th.BasicTeam) + th.AddUserToChannel(th.BasicUser, channel2) + th.AddUserToChannel(th.BasicUser, channel3) + th.AddUserToChannel(th.BasicUser, channel4) + th.AddUserToChannel(th.BasicUser, channel5) + th.AddUserToChannel(th.BasicUser, channel6) + + channels := [6]*model.Channel{th.BasicChannel, channel2, channel3, channel4, channel5, channel6} + + i := len(channels) + for _, channel := range channels { + for j := i; j > 0; j-- { + th.CreatePost(channel) + } + i-- + } + + expectedTopChannels := []struct { + ID string + MessageCount int64 + }{ + {ID: th.BasicChannel.Id, MessageCount: 7}, + {ID: channel2.Id, MessageCount: 5}, + {ID: channel3.Id, MessageCount: 4}, + {ID: channel4.Id, MessageCount: 3}, + {ID: channel5.Id, MessageCount: 2}, + } + + timeRange, _ := model.GetStartUnixMilliForTimeRange(model.TimeRangeToday) + + t.Run("get-top-channels-for-user-since", func(t *testing.T) { + topChannels, err := th.App.GetTopChannelsForUserSince(th.BasicUser.Id, "", &model.InsightsOpts{StartUnixMilli: timeRange, Page: 0, PerPage: 5}) + require.Nil(t, err) + + for i, channel := range topChannels.Items { + assert.Equal(t, expectedTopChannels[i].ID, channel.ID) + assert.Equal(t, expectedTopChannels[i].MessageCount, channel.MessageCount) + } + + topChannels, err = th.App.GetTopChannelsForUserSince(th.BasicUser.Id, th.BasicChannel.TeamId, &model.InsightsOpts{StartUnixMilli: timeRange, Page: 1, PerPage: 5}) + require.Nil(t, err) + assert.Equal(t, channel6.Id, topChannels.Items[0].ID) + assert.Equal(t, int64(1), topChannels.Items[0].MessageCount) + }) +} diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index ea2898e4824..647e6521bfc 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) GetTopChannelsForTeamSince(teamID string, userID string, opts *model.InsightsOpts) (*model.TopChannelList, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetTopChannelsForTeamSince") + + 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.GetTopChannelsForTeamSince(teamID, userID, opts) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetTopChannelsForUserSince(userID string, teamID string, opts *model.InsightsOpts) (*model.TopChannelList, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetTopChannelsForUserSince") + + 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.GetTopChannelsForUserSince(userID, teamID, opts) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + 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") diff --git a/i18n/en.json b/i18n/en.json index 1ae8f0b2ddf..ffcfffdc0cb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -4499,6 +4499,14 @@ "id": "app.channel.get_public_channels.get.app_error", "translation": "Unable to get public channels." }, + { + "id": "app.channel.get_top_for_team_since.app_error", + "translation": " " + }, + { + "id": "app.channel.get_top_for_user_since.app_error", + "translation": " " + }, { "id": "app.channel.get_unread.app_error", "translation": "Unable to get the channel unread messages." diff --git a/model/client4.go b/model/client4.go index e46112528ae..fec114e173c 100644 --- a/model/client4.go +++ b/model/client4.go @@ -3603,6 +3603,41 @@ func (c *Client4) AutocompleteChannelsForTeamForSearch(teamId, name string) (Cha return ch, BuildResponse(r), nil } +// GetTopChannelsForTeamSince will return an ordered list of the top channels in a given team. +func (c *Client4) GetTopChannelsForTeamSince(teamId string, timeRange string, page int, perPage int) (*TopChannelList, *Response, error) { + query := fmt.Sprintf("?time_range=%v&page=%v&per_page=%v", timeRange, page, perPage) + r, err := c.DoAPIGet(c.teamRoute(teamId)+"/top/channels"+query, "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var topChannels *TopChannelList + if jsonErr := json.NewDecoder(r.Body).Decode(&topChannels); jsonErr != nil { + return nil, nil, NewAppError("GetTopChannelsForTeamSince", "api.unmarshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + } + return topChannels, BuildResponse(r), nil +} + +// GetTopChannelsForUserSince will return an ordered list of your top channels in a given team. +func (c *Client4) GetTopChannelsForUserSince(teamId string, timeRange string, page int, perPage int) (*TopChannelList, *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/channels"+query, "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var topChannels *TopChannelList + if jsonErr := json.NewDecoder(r.Body).Decode(&topChannels); jsonErr != nil { + return nil, nil, NewAppError("GetTopChannelsForUserSince", "api.unmarshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + } + return topChannels, BuildResponse(r), nil +} + // Post Section // CreatePost creates a post based on the provided post struct. diff --git a/model/insights.go b/model/insights.go index e274bb83e3b..d28aec0f793 100644 --- a/model/insights.go +++ b/model/insights.go @@ -24,21 +24,32 @@ type InsightsListData struct { HasNext bool `json:"has_next"` } -type InsightsData struct { - Rank int `json:"rank"` -} - +// Top Reactions type TopReactionList struct { InsightsListData Items []*TopReaction `json:"items"` } type TopReaction struct { - InsightsData EmojiName string `json:"emoji_name"` Count int64 `json:"count"` } +// Top Channels +type TopChannelList struct { + InsightsListData + Items []*TopChannel `json:"items"` +} + +type TopChannel struct { + ID string `json:"id"` + Type ChannelType `json:"type"` + DisplayName string `json:"display_name"` + Name string `json:"name"` + TeamID string `json:"team_id"` + MessageCount int64 `json:"message_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) { @@ -56,10 +67,10 @@ func GetStartUnixMilliForTimeRange(timeRange string) (int64, *AppError) { 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 +// GetTopReactionListWithPagination 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 { +func GetTopReactionListWithPagination(reactions []*TopReaction, limit int) *TopReactionList { // Add pagination support var hasNext bool if (limit != 0) && (len(reactions) == limit+1) { @@ -67,10 +78,19 @@ func GetTopReactionListWithRankAndPagination(reactions []*TopReaction, limit int 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} } + +// GetTopChannelListWithPagination adds a rank to each item in the given list of TopChannel and checks if there is +// another page that can be fetched based on the given limit and offset. The given list of TopChannel is assumed to be +// sorted by Score. Returns a TopChannelList. +func GetTopChannelListWithPagination(channels []*TopChannel, limit int) *TopChannelList { + // Add pagination support + var hasNext bool + if (limit != 0) && (len(channels) == limit+1) { + hasNext = true + channels = channels[:len(channels)-1] + } + + return &TopChannelList{InsightsListData: InsightsListData{HasNext: hasNext}, Items: channels} +} diff --git a/model/insights_test.go b/model/insights_test.go index f1219adb5a6..ca1b371a120 100644 --- a/model/insights_test.go +++ b/model/insights_test.go @@ -29,8 +29,7 @@ func TestGetStartUnixMilliForTimeRang(t *testing.T) { } } -func TestGetTopReactionListWithRankAndPagination(t *testing.T) { - +func TestGetTopReactionListWithPagination(t *testing.T) { reactions := []*TopReaction{ {EmojiName: "smile", Count: 200}, {EmojiName: "+1", Count: 190}, @@ -61,21 +60,45 @@ func TestGetTopReactionListWithRankAndPagination(t *testing.T) { for _, test := range hasNextTC { t.Run(test.Description, func(t *testing.T) { - actual := GetTopReactionListWithRankAndPagination(reactions, test.Limit, test.Offset) + actual := GetTopReactionListWithPagination(reactions, test.Limit) + assert.Equal(t, test.Expected.HasNext, actual.HasNext) + }) + } +} + +func TestGetTopChannelListWithPagination(t *testing.T) { + channels := []*TopChannel{ + {ID: NewId(), MessageCount: 200}, + {ID: NewId(), MessageCount: 150}, + {ID: NewId(), MessageCount: 120}, + {ID: NewId(), MessageCount: 105}, + {ID: NewId(), MessageCount: 5}, + {ID: NewId(), MessageCount: 2}} + + hasNextTC := []struct { + Description string + Limit int + Offset int + Expected *TopChannelList + }{ + { + Description: "has one page", + Limit: len(channels), + Offset: 0, + Expected: &TopChannelList{InsightsListData: InsightsListData{HasNext: false}, Items: channels}, + }, + { + Description: "has more than one page", + Limit: len(channels) - 1, + Offset: 0, + Expected: &TopChannelList{InsightsListData: InsightsListData{HasNext: true}, Items: channels}, + }, + } + + for _, test := range hasNextTC { + t.Run(test.Description, func(t *testing.T) { + actual := GetTopChannelListWithPagination(channels, test.Limit) 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 8ee89e2778f..5f4431939be 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -1748,6 +1748,42 @@ func (s *OpenTracingLayerChannelStore) GetTeamMembersForChannel(channelID string return result, err } +func (s *OpenTracingLayerChannelStore) GetTopChannelsForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopChannelList, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.GetTopChannelsForTeamSince") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ChannelStore.GetTopChannelsForTeamSince(teamID, userID, since, offset, limit) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerChannelStore) GetTopChannelsForUserSince(userID string, teamID string, since int64, offset int, limit int) (*model.TopChannelList, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.GetTopChannelsForUserSince") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ChannelStore.GetTopChannelsForUserSince(userID, teamID, since, offset, limit) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerChannelStore) GroupSyncedChannelCount() (int64, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.GroupSyncedChannelCount") diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 293b66e4710..87a57f4cfd9 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -1975,6 +1975,48 @@ func (s *RetryLayerChannelStore) GetTeamMembersForChannel(channelID string) ([]s } +func (s *RetryLayerChannelStore) GetTopChannelsForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopChannelList, error) { + + tries := 0 + for { + result, err := s.ChannelStore.GetTopChannelsForTeamSince(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 *RetryLayerChannelStore) GetTopChannelsForUserSince(userID string, teamID string, since int64, offset int, limit int) (*model.TopChannelList, error) { + + tries := 0 + for { + result, err := s.ChannelStore.GetTopChannelsForUserSince(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 *RetryLayerChannelStore) GroupSyncedChannelCount() (int64, error) { tries := 0 diff --git a/store/sqlstore/channel_store.go b/store/sqlstore/channel_store.go index 2291c5a95c3..cbad73ccc3a 100644 --- a/store/sqlstore/channel_store.go +++ b/store/sqlstore/channel_store.go @@ -4097,3 +4097,161 @@ func (s SqlChannelStore) GetTeamForChannel(channelID string) (*model.Team, error } return &team, nil } + +// GetTopChannelsForTeamSince returns the filtered post counts of the following Channels sets: +// a) those that are private channels in the given user's membership graph on the given team, and +// b) those that are public channels in the given team. +func (s SqlChannelStore) GetTopChannelsForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopChannelList, error) { + channels := make([]*model.TopChannel, 0) + var args []interface{} + postgresPropQuery := `AND (Posts.Props ->> 'from_bot' IS NULL OR Posts.Props ->> 'from_bot' = 'false')` + mySqlPropsQuery := `AND (JSON_EXTRACT(Posts.Props, '$.from_bot') IS NULL OR JSON_EXTRACT(Posts.Props, '$.from_bot') = 'false')` + + query := ` + SELECT + ID, + Type, + DisplayName, + Name, + TeamID, + MessageCount + FROM + ((SELECT + Posts.ChannelId AS ID, + 'O' AS Type, + PublicChannels.DisplayName AS DisplayName, + PublicChannels.Name AS Name, + PublicChannels.TeamId AS TeamID, + count(Posts.Id) AS MessageCount, + PublicChannels.DeleteAt AS DeleteAt + FROM + Posts + LEFT JOIN PublicChannels on Posts.ChannelId = PublicChannels.Id + WHERE + Posts.DeleteAt = 0 + AND Posts.CreateAt > ? + AND Posts.Type = ''` + args = []interface{}{since} + + if s.DriverName() == model.DatabaseDriverMysql { + query += mySqlPropsQuery + } else if s.DriverName() == model.DatabaseDriverPostgres { + query += postgresPropQuery + } + + query += ` + AND PublicChannels.TeamId = ? + GROUP BY + Posts.ChannelId, + PublicChannels.DisplayName, + PublicChannels.Name, + PublicChannels.TeamId, + PublicChannels.DeleteAt) + UNION ALL + (SELECT + Posts.ChannelId AS ID, + Channels.Type AS Type, + Channels.DisplayName AS DisplayName, + Channels.Name AS Name, + Channels.TeamId AS TeamID, + count(Posts.Id) AS MessageCount, + Channels.DeleteAt AS DeleteAt + FROM + Posts + LEFT JOIN Channels on Posts.ChannelId = Channels.Id + LEFT JOIN ChannelMembers on Posts.ChannelId = ChannelMembers.ChannelId + WHERE + Posts.DeleteAt = 0 + AND Posts.CreateAt > ? + AND Posts.Type = ''` + args = append(args, teamID, since) + + if s.DriverName() == model.DatabaseDriverMysql { + query += mySqlPropsQuery + } else if s.DriverName() == model.DatabaseDriverPostgres { + query += postgresPropQuery + } + + query += ` + AND Channels.TeamId = ? + AND Channels.Type = 'P' + AND ChannelMembers.UserId = ? + GROUP BY + Posts.ChannelId, + Channels.Type, + Channels.DisplayName, + Channels.Name, + Channels.TeamId, + Channels.DeleteAt)) AS A + WHERE + DeleteAt = 0 + ORDER BY + MessageCount DESC, + Name ASC + LIMIT ? + OFFSET ?` + args = append(args, teamID, userID, limit+1, offset) + + if err := s.GetReplicaX().Select(&channels, query, args...); err != nil { + return nil, errors.Wrap(err, "failed to get top Channels") + } + + return model.GetTopChannelListWithPagination(channels, limit), nil +} + +// GetTopChannelsForUserSince returns the filtered post counts of channels with with posts created by the user +// after the given timestamp within the given team (or across the workspace if no team is given). Excludes DM and GM channels. +func (s SqlChannelStore) GetTopChannelsForUserSince(userID string, teamID string, since int64, offset int, limit int) (*model.TopChannelList, error) { + channels := make([]*model.TopChannel, 0) + var args []interface{} + var query string + + query = ` + SELECT + Posts.ChannelId AS ID, + Channels.Type AS Type, + Channels.DisplayName AS DisplayName, + Channels.Name AS Name, + Channels.TeamId AS TeamID, + count(Posts.Id) AS MessageCount + FROM + Posts + LEFT JOIN Channels on Posts.ChannelId = Channels.Id + LEFT JOIN ChannelMembers on Posts.ChannelId = ChannelMembers.ChannelId + WHERE + Posts.DeleteAt = 0 + AND Posts.CreateAt > ? + AND Posts.Type = '' + AND Posts.UserID = ? + AND Channels.DeleteAt = 0 + AND (Channels.Type = 'O' OR Channels.Type = 'P') + AND ChannelMembers.UserId = ?` + + args = []interface{}{since, userID, userID} + + if teamID != "" { + query += ` + AND Channels.TeamID = ?` + args = append(args, teamID) + } + + query += ` + Group By + Posts.ChannelId, + Channels.Type, + Channels.DisplayName, + Channels.Name, + Channels.TeamId + ORDER BY + MessageCount DESC, + Name ASC + LIMIT ? + OFFSET ?` + args = append(args, limit+1, offset) + + if err := s.GetReplicaX().Select(&channels, query, args...); err != nil { + return nil, errors.Wrap(err, "failed to get top Channels") + } + + return model.GetTopChannelListWithPagination(channels, limit), nil +} diff --git a/store/sqlstore/reaction_store.go b/store/sqlstore/reaction_store.go index c879d24041e..3248bc3e630 100644 --- a/store/sqlstore/reaction_store.go +++ b/store/sqlstore/reaction_store.go @@ -232,7 +232,7 @@ func (s *SqlReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int // 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 + reactions := make([]*model.TopReaction, 0) query := ` SELECT @@ -241,34 +241,41 @@ func (s *SqlReactionStore) GetTopForTeamSince(teamID string, userID string, sinc FROM (( SELECT EmojiName, - count(EmojiName) AS EmojiCount + count(EmojiName) AS EmojiCount, + Reactions.DeleteAt AS DeleteAt, + Reactions.CreateAt AS CreateAt 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 = ? + ChannelMembers.UserId = ? AND Channels.Type = 'P' AND Channels.TeamId = ? - AND Reactions.CreateAt > ? GROUP BY - Reactions.EmojiName) + Reactions.EmojiName, + Reactions.DeleteAt, + Reactions.CreateAt) UNION ALL ( SELECT EmojiName, - count(EmojiName) AS EmojiCount + count(EmojiName) AS EmojiCount, + Reactions.DeleteAt AS DeleteAt, + Reactions.CreateAt AS CreateAt FROM Reactions INNER JOIN Posts ON Reactions.PostId = Posts.Id INNER JOIN PublicChannels ON Posts.ChannelId = PublicChannels.Id WHERE - Reactions.DeleteAt = 0 - AND PublicChannels.TeamId = ? - AND Reactions.CreateAt > ? + PublicChannels.TeamId = ? GROUP BY - Reactions.EmojiName)) AS A + Reactions.EmojiName, + Reactions.DeleteAt, + Reactions.CreateAt)) AS A + WHERE + DeleteAt = 0 + AND CreateAt > ? GROUP BY EmojiName ORDER BY @@ -277,18 +284,18 @@ func (s *SqlReactionStore) GetTopForTeamSince(teamID string, userID string, sinc LIMIT ? OFFSET ?` - if err := s.GetReplicaX().Select(&reactions, query, userID, teamID, since, teamID, since, limit+1, offset); err != nil { + if err := s.GetReplicaX().Select(&reactions, query, userID, teamID, teamID, since, limit+1, offset); err != nil { return nil, errors.Wrap(err, "failed to get top Reactions") } - return model.GetTopReactionListWithRankAndPagination(reactions, limit, offset), nil + return model.GetTopReactionListWithPagination(reactions, limit), 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 + reactions := make([]*model.TopReaction, 0) var args []interface{} var query string @@ -339,7 +346,7 @@ func (s *SqlReactionStore) GetTopForUserSince(userID string, teamID string, sinc return nil, errors.Wrap(err, "failed to get top Reactions") } - return model.GetTopReactionListWithRankAndPagination(reactions, limit, offset), nil + return model.GetTopReactionListWithPagination(reactions, limit), nil } func (s *SqlReactionStore) saveReactionAndUpdatePost(transaction *sqlxTxWrapper, reaction *model.Reaction) error { diff --git a/store/store.go b/store/store.go index 45f4ee766de..9cd0e0b99c1 100644 --- a/store/store.go +++ b/store/store.go @@ -281,6 +281,10 @@ type ChannelStore interface { SetShared(channelId string, shared bool) error // GetTeamForChannel returns the team for a given channelID. GetTeamForChannel(channelID string) (*model.Team, error) + + // Insights + GetTopChannelsForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopChannelList, error) + GetTopChannelsForUserSince(userID string, teamID string, since int64, offset int, limit int) (*model.TopChannelList, error) } type ChannelMemberHistoryStore interface { diff --git a/store/storetest/mocks/ChannelStore.go b/store/storetest/mocks/ChannelStore.go index df0ae8db9e8..6b1a0e350c0 100644 --- a/store/storetest/mocks/ChannelStore.go +++ b/store/storetest/mocks/ChannelStore.go @@ -1508,6 +1508,52 @@ func (_m *ChannelStore) GetTeamMembersForChannel(channelID string) ([]string, er return r0, r1 } +// GetTopChannelsForTeamSince provides a mock function with given fields: teamID, userID, since, offset, limit +func (_m *ChannelStore) GetTopChannelsForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopChannelList, error) { + ret := _m.Called(teamID, userID, since, offset, limit) + + var r0 *model.TopChannelList + if rf, ok := ret.Get(0).(func(string, string, int64, int, int) *model.TopChannelList); ok { + r0 = rf(teamID, userID, since, offset, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.TopChannelList) + } + } + + 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 +} + +// GetTopChannelsForUserSince provides a mock function with given fields: userID, teamID, since, offset, limit +func (_m *ChannelStore) GetTopChannelsForUserSince(userID string, teamID string, since int64, offset int, limit int) (*model.TopChannelList, error) { + ret := _m.Called(userID, teamID, since, offset, limit) + + var r0 *model.TopChannelList + if rf, ok := ret.Get(0).(func(string, string, int64, int, int) *model.TopChannelList); ok { + r0 = rf(userID, teamID, since, offset, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.TopChannelList) + } + } + + 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 +} + // GroupSyncedChannelCount provides a mock function with given fields: func (_m *ChannelStore) GroupSyncedChannelCount() (int64, error) { ret := _m.Called() diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index ab505c93d99..c058bb40b21 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -1605,6 +1605,38 @@ func (s *TimerLayerChannelStore) GetTeamMembersForChannel(channelID string) ([]s return result, err } +func (s *TimerLayerChannelStore) GetTopChannelsForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopChannelList, error) { + start := timemodule.Now() + + result, err := s.ChannelStore.GetTopChannelsForTeamSince(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("ChannelStore.GetTopChannelsForTeamSince", success, elapsed) + } + return result, err +} + +func (s *TimerLayerChannelStore) GetTopChannelsForUserSince(userID string, teamID string, since int64, offset int, limit int) (*model.TopChannelList, error) { + start := timemodule.Now() + + result, err := s.ChannelStore.GetTopChannelsForUserSince(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("ChannelStore.GetTopChannelsForUserSince", success, elapsed) + } + return result, err +} + func (s *TimerLayerChannelStore) GroupSyncedChannelCount() (int64, error) { start := timemodule.Now() diff --git a/web/context.go b/web/context.go index 0295c2409b8..4adc4f95df8 100644 --- a/web/context.go +++ b/web/context.go @@ -374,17 +374,6 @@ 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 81f8d8c0667..4facaf8a1fe 100644 --- a/web/params.go +++ b/web/params.go @@ -31,7 +31,7 @@ type Params struct { TokenId string ThreadId string Timestamp int64 - TimeRange int64 + TimeRange string ChannelId string PostId string PolicyId string @@ -255,11 +255,7 @@ 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 - } + params.TimeRange = query.Get("time_range") if val, err := strconv.ParseBool(query.Get("permanent")); err == nil { params.Permanent = val