diff --git a/api4/insights.go b/api4/insights.go index 4e127eb19a..5e483ec9f5 100644 --- a/api4/insights.go +++ b/api4/insights.go @@ -21,8 +21,11 @@ func (api *API) InitInsights() { api.BaseRoutes.InsightsForUser.Handle("/channels", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopChannelsForUserSince)))).Methods("GET") // Threads - api.BaseRoutes.InsightsForTeam.Handle("/threads", api.APISessionRequired(requireLicense(getTopThreadsForTeamSince))).Methods("GET") - api.BaseRoutes.InsightsForUser.Handle("/threads", api.APISessionRequired(requireLicense(getTopThreadsForUserSince))).Methods("GET") + api.BaseRoutes.InsightsForTeam.Handle("/threads", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopThreadsForTeamSince)))).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/threads", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopThreadsForUserSince)))).Methods("GET") + + // user DMs + api.BaseRoutes.InsightsForUser.Handle("/dms", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopDMsForUserSince)))).Methods("GET") // New teammembers api.BaseRoutes.InsightsForTeam.Handle("/team_members", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getNewTeamMembersSince)))).Methods("GET") @@ -223,9 +226,9 @@ func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Reque return } - js, err := json.Marshal(topChannels) - if err != nil { - c.Err = model.NewAppError("getTopChannelsForUserSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + js, jsonErr := json.Marshal(topChannels) + if jsonErr != nil { + c.Err = model.NewAppError("getTopChannelsForUserSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) return } @@ -245,21 +248,14 @@ func getTopThreadsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reques return } - // license check - lic := c.App.Srv().License() - if lic.SkuShortName != model.LicenseShortSkuProfessional && lic.SkuShortName != model.LicenseShortSkuEnterprise { - c.Err = model.NewAppError("", "api.insights.license_error", nil, "", http.StatusNotImplemented) + // restrict users with no access to team + user, err := c.App.GetUser(c.AppContext.Session().UserId) + if err != nil { + c.Err = err return } - // restrict guests and users with no access to team - user, appErr := c.App.GetUser(c.AppContext.Session().UserId) - if appErr != nil { - c.Err = appErr - return - } - - if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) || user.IsGuest() { + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) { c.SetPermissionError(model.PermissionViewTeam) return } @@ -276,8 +272,8 @@ func getTopThreadsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reques return } - js, err := json.Marshal(topThreads) - if err != nil { + js, jsonError := json.Marshal(topThreads) + if jsonError != nil { c.Err = model.NewAppError("getTopThreadsForTeamSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) return } @@ -288,10 +284,10 @@ func getTopThreadsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reques func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { c.Params.TeamId = r.URL.Query().Get("team_id") - // restrict guests and users with no access to team - user, appErr := c.App.GetUser(c.AppContext.Session().UserId) - if appErr != nil { - c.Err = appErr + // restrict users with no access to team + user, err := c.App.GetUser(c.AppContext.Session().UserId) + if err != nil { + c.Err = err return } // TeamId is an optional parameter @@ -307,14 +303,7 @@ func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Reques return } - // license check - lic := c.App.Srv().License() - if lic.SkuShortName != model.LicenseShortSkuProfessional && lic.SkuShortName != model.LicenseShortSkuEnterprise { - c.Err = model.NewAppError("", "api.insights.license_error", nil, "", http.StatusNotImplemented) - return - } - - if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) || user.IsGuest() { + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) { c.SetPermissionError(model.PermissionViewTeam) return } @@ -332,9 +321,39 @@ func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Reques return } - js, err := json.Marshal(topThreads) + js, jsonErr := json.Marshal(topThreads) + if jsonErr != nil { + c.Err = model.NewAppError("getTopThreadsForUserSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) + return + } + + w.Write(js) +} + +// Top DMs +func getTopDMsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + user, err := c.App.GetUser(c.AppContext.Session().UserId) if err != nil { - c.Err = model.NewAppError("getTopThreadsForUserSince", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + c.Err = err + return + } + + startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + + topDMs, err := c.App.GetTopDMsForUserSince(user.Id, &model.InsightsOpts{ + StartUnixMilli: startTime.UnixMilli(), + Page: c.Params.Page, + PerPage: c.Params.PerPage, + }) + + if err != nil { + c.Err = err + return + } + + js, jsonErr := json.Marshal(topDMs) + if jsonErr != nil { + c.Err = model.NewAppError("getTopDMsForUserSince", "api.marshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) return } diff --git a/api4/insights_test.go b/api4/insights_test.go index 59c5a848fc..5ba6f61972 100644 --- a/api4/insights_test.go +++ b/api4/insights_test.go @@ -817,6 +817,126 @@ func TestGetTopThreadsForUserSince(t *testing.T) { require.Len(t, topUser2ThreadsAfterPrivateReplyDelete.Items, 0) } +func TestGetTopDMsForUserSince(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.ConfigStore.SetReadOnlyFF(false) + defer th.ConfigStore.SetReadOnlyFF(true) + th.App.UpdateConfig(func(c *model.Config) { + *c.TeamSettings.EnableUserDeactivation = true + }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableBotAccountCreation = true }) + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + + // basicuser1 - bu1, basicuser - bu + // create dm channels for bu-bu, bu1-bu1, bu-bu1, bot-bu + basicUser := th.BasicUser + basicUser1 := th.BasicUser2 + + th.LoginBasic2() + client := th.Client + channelBu1Bu1, _, err := client.CreateDirectChannel(basicUser1.Id, basicUser1.Id) + require.NoError(t, err) + + th.LoginBasic() + client = th.Client + channelBuBu, _, err := client.CreateDirectChannel(basicUser.Id, basicUser.Id) + require.NoError(t, err) + channelBuBu1, _, err := client.CreateDirectChannel(basicUser.Id, basicUser1.Id) + require.NoError(t, err) + + // bot creation with permission + th.AddPermissionToRole(model.PermissionCreateBot.Id, model.TeamUserRoleId) + th.App.UpdateUserRoles(th.Context, th.BasicUser.Id, model.TeamUserRoleId+" "+model.SystemUserRoleId, false) + bot := &model.Bot{ + Username: GenerateTestUsername(), + DisplayName: "a bot", + Description: "bot", + } + + createdBot, resp, err := th.Client.CreateBot(bot) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + defer th.App.PermanentDeleteBot(createdBot.UserId) + channelBuBot, _, err := client.CreateDirectChannel(basicUser.Id, createdBot.UserId) + require.NoError(t, err) + + // create 2 posts in channelBu, 1 in channelBu1, 3 in channelBu12 + postsGenConfig := []map[string]interface{}{ + { + "chId": channelBuBu.Id, + "postCount": 2, + }, + { + "chId": channelBu1Bu1.Id, + "postCount": 1, + }, + { + "chId": channelBuBu1.Id, + "postCount": 3, + }, + { + "chId": channelBuBot.Id, + "postCount": 3, + }, + } + + for _, postGen := range postsGenConfig { + postCount := postGen["postCount"].(int) + for i := 0; i < postCount; i++ { + if postGen["chId"] == channelBu1Bu1.Id { + th.LoginBasic2() + client = th.Client + userId := basicUser1.Id + post := &model.Post{UserId: userId, ChannelId: postGen["chId"].(string), Message: "zz" + model.NewId() + "a"} + _, _, err = client.CreatePost(post) + require.NoError(t, err) + } else { + th.LoginBasic() + client = th.Client + userId := basicUser.Id + post := &model.Post{UserId: userId, ChannelId: postGen["chId"].(string), Message: "zz" + model.NewId() + "a"} + _, _, err = client.CreatePost(post) + require.NoError(t, err) + } + } + } + + // get top dms for bu + t.Run("get top dms for basic user 1", func(t *testing.T) { + th.LoginBasic() + client = th.Client + topDMs, _, topDmsErr := client.GetTopDMsForUserSince("today", 0, 100) + require.NoError(t, topDmsErr) + require.Len(t, topDMs.Items, 1) + require.Equal(t, topDMs.Items[0].MessageCount, int64(3)) + require.Equal(t, topDMs.Items[0].SecondParticipant.Id, basicUser1.Id) + }) + + // get top dms for bu1 + t.Run("get top dms for basic user 2", func(t *testing.T) { + th.LoginBasic2() + client = th.Client + topDMs, _, topDmsErr := client.GetTopDMsForUserSince("today", 0, 100) + require.NoError(t, topDmsErr) + require.Len(t, topDMs.Items, 1) + require.Equal(t, topDMs.Items[0].MessageCount, int64(3)) + }) + // deactivate basicuser1 + _, err = th.Client.DeleteUser(basicUser1.Id) + require.NoError(t, err) + // deactivated users DMs should show in topDMs + t.Run("get top dms for basic user 1", func(t *testing.T) { + th.LoginBasic() + client = th.Client + topDMs, _, topDmsErr := client.GetTopDMsForUserSince("today", 0, 100) + require.NoError(t, topDmsErr) + require.Len(t, topDMs.Items, 1) + require.Equal(t, topDMs.Items[0].MessageCount, int64(3)) + }) +} + func TestNewTeamMembersSince(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/app/app_iface.go b/app/app_iface.go index bdf5aa54de..32b6c04022 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -793,6 +793,7 @@ type AppIface interface { GetTokenById(token string) (*model.Token, *model.AppError) GetTopChannelsForTeamSince(c request.CTX, teamID, userID string, opts *model.InsightsOpts) (*model.TopChannelList, *model.AppError) GetTopChannelsForUserSince(c request.CTX, userID, teamID string, opts *model.InsightsOpts) (*model.TopChannelList, *model.AppError) + GetTopDMsForUserSince(userID string, opts *model.InsightsOpts) (*model.TopDMList, *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) GetTopThreadsForTeamSince(c request.CTX, teamID, userID string, opts *model.InsightsOpts) (*model.TopThreadList, *model.AppError) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 6e0293f357..d26928490a 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -9955,6 +9955,28 @@ func (a *OpenTracingAppLayer) GetTopChannelsForUserSince(c request.CTX, userID s return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetTopDMsForUserSince(userID string, opts *model.InsightsOpts) (*model.TopDMList, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetTopDMsForUserSince") + + 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.GetTopDMsForUserSince(userID, 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/app/post.go b/app/post.go index e7b1fc214d..a70760e5dc 100644 --- a/app/post.go +++ b/app/post.go @@ -1939,6 +1939,17 @@ func (a *App) GetTopThreadsForUserSince(c request.CTX, teamID, userID string, op return topThreadsWithEmbedAndImage, nil } +func (a *App) GetTopDMsForUserSince(userID string, opts *model.InsightsOpts) (*model.TopDMList, *model.AppError) { + if !a.Config().FeatureFlags.InsightsEnabled { + return nil, model.NewAppError("GetTopDMsForUserSince", "app.insights.feature_disabled", nil, "", http.StatusNotImplemented) + } + topDMs, err := a.Srv().Store.Post().GetTopDMsForUserSince(userID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage) + if err != nil { + return nil, model.NewAppError("GetTopDMsForUserSince", "app.post.get_top_dms_for_user_since.app_error", nil, err.Error(), http.StatusInternalServerError) + } + return topDMs, nil +} + func (a *App) SetPostReminder(postID, userID string, targetTime int64) *model.AppError { // Store the reminder in the DB reminder := &model.PostReminder{ diff --git a/app/post_test.go b/app/post_test.go index 3e5429ffc0..2ca888c004 100644 --- a/app/post_test.go +++ b/app/post_test.go @@ -3062,3 +3062,103 @@ func TestGetTopThreadsForUserSince(t *testing.T) { require.Nil(t, appErr) require.Len(t, topUser2ThreadsAfterPrivateReplyDelete.Items, 0) } + +func TestGetTopDMsForUserSince(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.InsightsEnabled = true }) + + // users + user := th.CreateUser() + u1 := th.CreateUser() + u2 := th.CreateUser() + u3 := th.CreateUser() + u4 := th.CreateUser() + // user direct messages + chUser1, nErr := th.App.createDirectChannel(th.Context, u1.Id, user.Id) + fmt.Println(chUser1, nErr) + require.Nil(t, nErr) + chUser2, nErr := th.App.createDirectChannel(th.Context, u2.Id, user.Id) + require.Nil(t, nErr) + chUser3, nErr := th.App.createDirectChannel(th.Context, u3.Id, user.Id) + require.Nil(t, nErr) + // other user direct message + chUser3User4, nErr := th.App.createDirectChannel(th.Context, u3.Id, u4.Id) + require.Nil(t, nErr) + + // sample post data + // for u1 + _, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + ChannelId: chUser1.Id, + UserId: u1.Id, + }, "", false) + require.Nil(t, err) + _, err = th.App.CreatePostAsUser(th.Context, &model.Post{ + ChannelId: chUser1.Id, + UserId: user.Id, + }, "", false) + require.Nil(t, err) + // for u2: 1 post + _, err = th.App.CreatePostAsUser(th.Context, &model.Post{ + ChannelId: chUser2.Id, + UserId: u2.Id, + }, "", false) + require.Nil(t, err) + // for user-u3: 3 posts + for i := 0; i < 3; i++ { + _, err = th.App.CreatePostAsUser(th.Context, &model.Post{ + ChannelId: chUser3.Id, + UserId: user.Id, + }, "", false) + require.Nil(t, err) + } + // for u4-u3: 4 posts + _, err = th.App.CreatePostAsUser(th.Context, &model.Post{ + ChannelId: chUser3User4.Id, + UserId: u3.Id, + }, "", false) + require.Nil(t, err) + _, err = th.App.CreatePostAsUser(th.Context, &model.Post{ + ChannelId: chUser3User4.Id, + UserId: u4.Id, + }, "", false) + require.Nil(t, err) + _, err = th.App.CreatePostAsUser(th.Context, &model.Post{ + ChannelId: chUser3User4.Id, + UserId: u3.Id, + }, "", false) + require.Nil(t, err) + + _, err = th.App.CreatePostAsUser(th.Context, &model.Post{ + ChannelId: chUser3User4.Id, + UserId: u4.Id, + }, "", false) + require.Nil(t, err) + + t.Run("should return topDMs when userid is specified ", func(t *testing.T) { + topDMs, err := th.App.GetTopDMsForUserSince(user.Id, &model.InsightsOpts{StartUnixMilli: 100, Page: 0, PerPage: 100}) + require.Nil(t, err) + // len of topDMs.Items should be 3 + require.Len(t, topDMs.Items, 3) + // check order, magnitude of items + // fmt.Println(topDMs.Items[0].MessageCount, topDMs.Items[1].MessageCount, topDMs.Items[2].MessageCount) + require.Equal(t, topDMs.Items[0].SecondParticipant.Id, u3.Id) + require.Equal(t, topDMs.Items[0].MessageCount, int64(3)) + require.Equal(t, topDMs.Items[1].SecondParticipant.Id, u1.Id) + require.Equal(t, topDMs.Items[1].MessageCount, int64(2)) + require.Equal(t, topDMs.Items[2].SecondParticipant.Id, u2.Id) + require.Equal(t, topDMs.Items[2].MessageCount, int64(1)) + // this also ensures that u3-u4 conversation doesn't show up in others' top DMs. + }) + t.Run("topDMs should only consider user's DM channels ", func(t *testing.T) { + // u4 only takes part in one conversation + topDMs, err := th.App.GetTopDMsForUserSince(u4.Id, &model.InsightsOpts{StartUnixMilli: 100, Page: 0, PerPage: 100}) + require.Nil(t, err) + // len of topDMs.Items should be 3 + require.Len(t, topDMs.Items, 1) + // check order, magnitude of items + require.Equal(t, topDMs.Items[0].SecondParticipant.Id, u3.Id) + require.Equal(t, topDMs.Items[0].MessageCount, int64(4)) + }) +} diff --git a/i18n/en.json b/i18n/en.json index e2298bf8c6..b69aaf6917 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1913,10 +1913,6 @@ "id": "api.insights.feature_disabled", "translation": "Insights is behind a feature flag which is not enabled." }, - { - "id": "api.insights.license_error", - "translation": "Your license doesn't support Insights feature." - }, { "id": "api.invalid_channel", "translation": "Channel listed in the request doesn't belong to the user" @@ -5859,6 +5855,10 @@ "id": "app.post.get_root_posts.app_error", "translation": "Unable to get the posts for the channel." }, + { + "id": "app.post.get_top_dms_for_user_since.app_error", + "translation": "Unable to get top DMs for user." + }, { "id": "app.post.get_top_threads_for_team_since.app_error", "translation": "Unable to get top threads for team." diff --git a/model/client4.go b/model/client4.go index b217af9b8e..dc1015c17b 100644 --- a/model/client4.go +++ b/model/client4.go @@ -6698,6 +6698,21 @@ func (c *Client4) GetTopReactionsForUserSince(teamId string, timeRange string, p return topReactions, BuildResponse(r), nil } +func (c *Client4) GetTopDMsForUserSince(timeRange string, page int, perPage int) (*TopDMList, *Response, error) { + query := fmt.Sprintf("?time_range=%v&page=%v&per_page=%v", timeRange, page, perPage) + + r, err := c.DoAPIGet(c.usersRoute()+"/me/top/dms"+query, "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var topDMs *TopDMList + if jsonErr := json.NewDecoder(r.Body).Decode(&topDMs); jsonErr != nil { + return nil, nil, NewAppError("GetTopReactionsForUserSince", "api.unmarshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + } + return topDMs, 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 index 4d2d89fade..e6a5602829 100644 --- a/model/insights.go +++ b/model/insights.go @@ -90,6 +90,10 @@ type InsightUserInformation struct { Username string `json:"username"` } +type TopDMInsightUserInformation struct { + InsightUserInformation + Position string `json:"position"` +} type NewTeamMembersList struct { InsightsListData Items []*NewTeamMember `json:"items"` @@ -113,6 +117,25 @@ type DurationPostCount struct { PostCount int `db:"postcount"` } +// Top DMs +type TopDM struct { + MessageCount int64 `json:"post_count"` + OutgoingMessageCount int64 `json:"outgoing_message_count"` + Participants string `json:"-"` + ChannelId string `json:"-"` + SecondParticipant *TopDMInsightUserInformation `json:"second_participant"` +} + +type OutgoingMessageQueryResult struct { + ChannelId string + MessageCount int +} + +type TopDMList struct { + InsightsListData + Items []*TopDM `json:"items"` +} + func TimeRangeToNumberDays(timeRange string) int { var n int switch timeRange { @@ -263,6 +286,20 @@ func GetTopThreadListWithPagination(threads []*TopThread, limit int) *TopThreadL return &TopThreadList{InsightsListData: InsightsListData{HasNext: hasNext}, Items: threads} } +// GetTopDMListWithPagination adds a rank to each item in the given list of TopDM and checks if there is +// another page that can be fetched based on the given limit and offset. The given list of TopDM is assumed to be +// sorted by MessageCount(score). Returns a TopDMList. +func GetTopDMListWithPagination(dms []*TopDM, limit int) *TopDMList { + // Add pagination support + var hasNext bool + if (limit != 0) && (len(dms) == limit+1) { + hasNext = true + dms = dms[:len(dms)-1] + } + + return &TopDMList{InsightsListData: InsightsListData{HasNext: hasNext}, Items: dms} +} + func GetNewTeamMembersListWithPagination(teamMembers []*NewTeamMember, limit int) *NewTeamMembersList { var hasNext bool if (limit != 0) && (len(teamMembers) == limit+1) { diff --git a/model/insights_test.go b/model/insights_test.go index 671e0443fb..5505cc1bc8 100644 --- a/model/insights_test.go +++ b/model/insights_test.go @@ -120,3 +120,41 @@ func TestGetTopThreadListWithPagination(t *testing.T) { }) } } + +func TestGetTopDMsListWithPagination(t *testing.T) { + dms := []*TopDM{ + {SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 100}, + {SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 80}, + {SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 90}, + {SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 76}, + {SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 43}, + {SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 2}, + {SecondParticipant: &TopDMInsightUserInformation{InsightUserInformation: InsightUserInformation{Id: NewId()}}, MessageCount: 1}, + } + hasNextTT := []struct { + Description string + Limit int + Offset int + Expected *TopDMList + }{ + { + Description: "has one page", + Limit: len(dms), + Offset: 0, + Expected: &TopDMList{InsightsListData: InsightsListData{HasNext: false}, Items: dms}, + }, + { + Description: "has more than one page", + Limit: len(dms) - 1, + Offset: 0, + Expected: &TopDMList{InsightsListData: InsightsListData{HasNext: true}, Items: dms}, + }, + } + + for _, test := range hasNextTT { + t.Run(test.Description, func(t *testing.T) { + actual := GetTopDMListWithPagination(dms, test.Limit) + assert.Equal(t, test.Expected.HasNext, actual.HasNext) + }) + } +} diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index e4dc543a8c..8d19d63f40 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -6043,6 +6043,24 @@ func (s *OpenTracingLayerPostStore) GetSingle(id string, inclDeleted bool) (*mod return result, err } +func (s *OpenTracingLayerPostStore) GetTopDMsForUserSince(userID string, since int64, offset int, limit int) (*model.TopDMList, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostStore.GetTopDMsForUserSince") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostStore.GetTopDMsForUserSince(userID, since, offset, limit) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerPostStore) HasAutoResponsePostByUserSince(options model.GetPostsSinceOptions, userId string) (bool, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostStore.HasAutoResponsePostByUserSince") diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 8888c13772..3c6c6d97e4 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -6848,6 +6848,27 @@ func (s *RetryLayerPostStore) GetSingle(id string, inclDeleted bool) (*model.Pos } +func (s *RetryLayerPostStore) GetTopDMsForUserSince(userID string, since int64, offset int, limit int) (*model.TopDMList, error) { + + tries := 0 + for { + result, err := s.PostStore.GetTopDMsForUserSince(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 *RetryLayerPostStore) HasAutoResponsePostByUserSince(options model.GetPostsSinceOptions, userId string) (bool, error) { tries := 0 diff --git a/store/sqlstore/post_store.go b/store/sqlstore/post_store.go index a2e8c228e7..fc67b29599 100644 --- a/store/sqlstore/post_store.go +++ b/store/sqlstore/post_store.go @@ -2986,6 +2986,148 @@ func (s *SqlPostStore) updateThreadsFromPosts(transaction *sqlxTxWrapper, posts return nil } +func (s *SqlPostStore) GetTopDMsForUserSince(userID string, since int64, offset int, limit int) (*model.TopDMList, error) { + channelSelector := s.getQueryBuilder().Select("Id", "TotalMsgCount").From("Channels").Join("ChannelMembers as cm on cm.ChannelId = Channels.Id"). + Where(sq.And{ + sq.Expr("Channels.Type = 'D'"), + sq.Eq{"cm.UserId": userID}, + }) + var aggregator string + + if s.DriverName() == model.DatabaseDriverMysql { + aggregator = "group_concat(distinct cm.UserId) as Participants" + } else { + aggregator = "string_agg(distinct cm.UserId, ',') as Participants" + } + + topDMsBuilder := s.getQueryBuilder().Select("vch.TotalMsgCount as MessageCount", aggregator, "vch.Id as ChannelId").FromSelect(channelSelector, "vch"). + Join("ChannelMembers as cm on cm.ChannelId = vch.Id"). + Join("Posts as p on p.ChannelId = vch.Id"). + Where(sq.And{ + sq.Gt{ + "p.UpdateAt": since, + }, + sq.Eq{ + "p.DeleteAt": 0, + }, + }).GroupBy("vch.id", "vch.TotalMsgCount") + + topDMsBuilder = topDMsBuilder.OrderBy("MessageCount DESC").Limit(uint64(limit + 1)).Offset(uint64(offset)) + + topDMs := make([]*model.TopDM, 0) + sql, args, err := topDMsBuilder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "GetTopDMsForUserSince_ToSql") + } + err = s.GetReplicaX().Select(&topDMs, sql, args...) + if err != nil { + return nil, errors.Wrapf(err, "failed to find top DMs for user-id: %s", userID) + } + + // fill SecondParticipant column + topDMs, err = postProcessTopDMs(s, userID, topDMs, since) + if err != nil { + return nil, err + } + return model.GetTopDMListWithPagination(topDMs, limit), nil +} + +func postProcessTopDMs(s *SqlPostStore, userID string, topDMs []*model.TopDM, since int64) ([]*model.TopDM, error) { + var topDMsFiltered = []*model.TopDM{} + var secondParticipantIds []string + var channelIds []string + + // identify second participant in a list of participants + for _, topDM := range topDMs { + participants := strings.Split(topDM.Participants, ",") + var secondParticipantId string + if len(participants) == 1 { + // channel with self + secondParticipantId = "-1" + } else { + if participants[0] == userID { + secondParticipantId = participants[1] + } else { + secondParticipantId = participants[0] + } + } + secondParticipantIds = append(secondParticipantIds, secondParticipantId) + channelIds = append(channelIds, topDM.ChannelId) + } + + // get user profiles + users, err := s.User().GetProfileByIds(context.Background(), secondParticipantIds, &store.UserGetByIdsOpts{}, true) + if err != nil { + return nil, errors.Wrapf(err, "failed to get second participants' information") + } + + // get outgoing message count for userId + outgoingMessagesQuery := s.getQueryBuilder().Select("ch.Id as ChannelId, count(p.Id) as MessageCount").From("Channels as ch"). + Join("Posts as p on p.ChannelId=ch.Id").Where( + sq.And{ + sq.Gt{ + "p.UpdateAt": since, + }, + sq.Eq{ + "p.DeleteAt": 0, + }, + sq.Eq{ + "ch.Id": channelIds, + }, + sq.Eq{ + "p.UserId": userID, + }, + }).GroupBy("ch.Id") + + outgoingMessages := make([]*model.OutgoingMessageQueryResult, 0) + sql, args, err := outgoingMessagesQuery.ToSql() + if err != nil { + return nil, errors.Wrap(err, "GetTopDMsForUserSince_outgoingMessagesQuery_ToSql") + } + err = s.GetReplicaX().Select(&outgoingMessages, sql, args...) + if err != nil { + return nil, errors.Wrapf(err, "failed to find top DMs for user-id: %s", userID) + } + + // create map of channelId -> MessageCount + outgoingMessagesMap := make(map[string]int) + for _, outgoingMessage := range outgoingMessages { + outgoingMessagesMap[outgoingMessage.ChannelId] = outgoingMessage.MessageCount + } + + // create map of userId -> User + usersMap := make(map[string]*model.User) + for _, user := range users { + usersMap[user.Id] = user + } + + for index, topDM := range topDMs { + if secondParticipantIds[index] == "-1" { + continue + } + user := usersMap[secondParticipantIds[index]] + if user.IsBot { + continue + } + topDM.SecondParticipant = &model.TopDMInsightUserInformation{ + InsightUserInformation: model.InsightUserInformation{ + Id: user.Id, + LastPictureUpdate: user.LastPictureUpdate, + FirstName: user.FirstName, + LastName: user.LastName, + Username: user.Username, + NickName: user.Nickname, + }, + Position: user.Position, + } + + topDM.OutgoingMessageCount = int64(outgoingMessagesMap[topDM.ChannelId]) + topDMsFiltered = append(topDMsFiltered, topDM) + } + + return topDMsFiltered, nil +} + func (s *SqlPostStore) SetPostReminder(reminder *model.PostReminder) error { transaction, err := s.GetMasterX().Beginx() if err != nil { diff --git a/store/store.go b/store/store.go index af76210f4f..94480b8ffe 100644 --- a/store/store.go +++ b/store/store.go @@ -393,6 +393,9 @@ type PostStore interface { GetPostReminderMetadata(postID string) (*PostReminderMetadata, error) // GetNthRecentPostTime returns the CreateAt time of the nth most recent post. GetNthRecentPostTime(n int64) (int64, error) + + // Insights - top DMs + GetTopDMsForUserSince(userID string, since int64, offset int, limit int) (*model.TopDMList, error) } type UserStore interface { diff --git a/store/storetest/mocks/PostStore.go b/store/storetest/mocks/PostStore.go index 42977fb9e3..ef455c82b3 100644 --- a/store/storetest/mocks/PostStore.go +++ b/store/storetest/mocks/PostStore.go @@ -748,6 +748,29 @@ func (_m *PostStore) GetSingle(id string, inclDeleted bool) (*model.Post, error) return r0, r1 } +// GetTopDMsForUserSince provides a mock function with given fields: userID, since, offset, limit +func (_m *PostStore) GetTopDMsForUserSince(userID string, since int64, offset int, limit int) (*model.TopDMList, error) { + ret := _m.Called(userID, since, offset, limit) + + var r0 *model.TopDMList + if rf, ok := ret.Get(0).(func(string, int64, int, int) *model.TopDMList); ok { + r0 = rf(userID, since, offset, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.TopDMList) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, int64, int, int) error); ok { + r1 = rf(userID, since, offset, limit) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // HasAutoResponsePostByUserSince provides a mock function with given fields: options, userId func (_m *PostStore) HasAutoResponsePostByUserSince(options model.GetPostsSinceOptions, userId string) (bool, error) { ret := _m.Called(options, userId) diff --git a/store/storetest/post_store.go b/store/storetest/post_store.go index da7c16137c..e7e69d2cb3 100644 --- a/store/storetest/post_store.go +++ b/store/storetest/post_store.go @@ -62,6 +62,7 @@ func TestPostStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("GetPostReminders", func(t *testing.T) { testGetPostReminders(t, ss, s) }) t.Run("GetPostReminderMetadata", func(t *testing.T) { testGetPostReminderMetadata(t, ss, s) }) t.Run("GetNthRecentPostTime", func(t *testing.T) { testGetNthRecentPostTime(t, ss) }) + t.Run("GetTopDMsForUserSince", func(t *testing.T) { testGetTopDMsForUserSince(t, ss, s) }) } func testPostStoreSave(t *testing.T, ss store.Store) { @@ -4058,3 +4059,124 @@ func testGetNthRecentPostTime(t *testing.T, ss store.Store) { assert.Error(t, err) assert.IsType(t, &store.ErrNotFound{}, err) } + +func testGetTopDMsForUserSince(t *testing.T, ss store.Store, s SqlStore) { + // users + user := model.User{Email: MakeEmail(), Username: model.NewId()} + u1 := model.User{Email: MakeEmail(), Username: model.NewId()} + u2 := model.User{Email: MakeEmail(), Username: model.NewId()} + u3 := model.User{Email: MakeEmail(), Username: model.NewId()} + u4 := model.User{Email: MakeEmail(), Username: model.NewId()} + _, err := ss.User().Save(&user) + require.NoError(t, err) + _, err = ss.User().Save(&u1) + require.NoError(t, err) + _, err = ss.User().Save(&u2) + require.NoError(t, err) + _, err = ss.User().Save(&u3) + require.NoError(t, err) + _, err = ss.User().Save(&u4) + require.NoError(t, err) + // user direct messages + chUser1, nErr := ss.Channel().CreateDirectChannel(&u1, &user) + require.NoError(t, nErr) + chUser2, nErr := ss.Channel().CreateDirectChannel(&u2, &user) + require.NoError(t, nErr) + chUser3, nErr := ss.Channel().CreateDirectChannel(&u3, &user) + require.NoError(t, nErr) + // other user direct message + chUser3User4, nErr := ss.Channel().CreateDirectChannel(&u3, &u4) + require.NoError(t, nErr) + + // sample post data + // for u1 + _, err = ss.Post().Save(&model.Post{ + ChannelId: chUser1.Id, + UserId: u1.Id, + }) + require.NoError(t, err) + _, err = ss.Post().Save(&model.Post{ + ChannelId: chUser1.Id, + UserId: user.Id, + }) + require.NoError(t, err) + // for u2: 1 post + postToDelete, err := ss.Post().Save(&model.Post{ + ChannelId: chUser2.Id, + UserId: u2.Id, + }) + require.NoError(t, err) + // for user-u3: 3 posts + for i := 0; i < 3; i++ { + _, err = ss.Post().Save(&model.Post{ + ChannelId: chUser3.Id, + UserId: user.Id, + }) + require.NoError(t, err) + } + // for u4-u3: 4 posts + _, err = ss.Post().Save(&model.Post{ + ChannelId: chUser3User4.Id, + UserId: u3.Id, + }) + require.NoError(t, err) + _, err = ss.Post().Save(&model.Post{ + ChannelId: chUser3User4.Id, + UserId: u4.Id, + }) + require.NoError(t, err) + _, err = ss.Post().Save(&model.Post{ + ChannelId: chUser3User4.Id, + UserId: u3.Id, + }) + require.NoError(t, err) + + _, err = ss.Post().Save(&model.Post{ + ChannelId: chUser3User4.Id, + UserId: u4.Id, + }) + require.NoError(t, err) + t.Run("should return topDMs when userid is specified ", func(t *testing.T) { + topDMs, storeErr := ss.Post().GetTopDMsForUserSince(user.Id, 100, 0, 100) + require.NoError(t, storeErr) + // len of topDMs.Items should be 3 + require.Len(t, topDMs.Items, 3) + // check order, magnitude of items + require.Equal(t, topDMs.Items[0].SecondParticipant.Id, u3.Id) + require.Equal(t, topDMs.Items[0].MessageCount, int64(3)) + require.Equal(t, topDMs.Items[0].OutgoingMessageCount, int64(3)) + require.Equal(t, topDMs.Items[1].SecondParticipant.Id, u1.Id) + require.Equal(t, topDMs.Items[1].MessageCount, int64(2)) + require.Equal(t, topDMs.Items[1].OutgoingMessageCount, int64(1)) + require.Equal(t, topDMs.Items[2].SecondParticipant.Id, u2.Id) + require.Equal(t, topDMs.Items[2].MessageCount, int64(1)) + require.Equal(t, topDMs.Items[2].OutgoingMessageCount, int64(0)) + // this also ensures that u3-u4 conversation doesn't show up in others' top DMs. + }) + t.Run("topDMs should only consider user's DM channels ", func(t *testing.T) { + // u4 only takes part in one conversation + topDMs, storeErr := ss.Post().GetTopDMsForUserSince(u4.Id, 100, 0, 100) + require.NoError(t, storeErr) + // len of topDMs.Items should be 3 + require.Len(t, topDMs.Items, 1) + // check order, magnitude of items + require.Equal(t, topDMs.Items[0].SecondParticipant.Id, u3.Id) + require.Equal(t, topDMs.Items[0].MessageCount, int64(4)) + }) + t.Run("topDMs will not consider self dms", func(t *testing.T) { + chUser, nErr := ss.Channel().CreateDirectChannel(&user, &user) + require.NoError(t, nErr) + _, err = ss.Post().Save(&model.Post{ + ChannelId: chUser.Id, + UserId: user.Id, + }) + // delete u2 post + err := ss.Post().Delete(postToDelete.Id, 200, user.Id) + require.NoError(t, err) + // u4 only takes part in one conversation + topDMs, err := ss.Post().GetTopDMsForUserSince(user.Id, 100, 0, 100) + require.NoError(t, err) + // len of topDMs.Items should be 3 + require.Len(t, topDMs.Items, 2) + }) +} diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 367770da9d..5fb7b55deb 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -5466,6 +5466,22 @@ func (s *TimerLayerPostStore) GetSingle(id string, inclDeleted bool) (*model.Pos return result, err } +func (s *TimerLayerPostStore) GetTopDMsForUserSince(userID string, since int64, offset int, limit int) (*model.TopDMList, error) { + start := time.Now() + + result, err := s.PostStore.GetTopDMsForUserSince(userID, since, offset, limit) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostStore.GetTopDMsForUserSince", success, elapsed) + } + return result, err +} + func (s *TimerLayerPostStore) HasAutoResponsePostByUserSince(options model.GetPostsSinceOptions, userId string) (bool, error) { start := time.Now()