diff --git a/api4/insights.go b/api4/insights.go index 817dec6ce0..db2bda4e36 100644 --- a/api4/insights.go +++ b/api4/insights.go @@ -23,6 +23,9 @@ func (api *API) InitInsights() { // Threads 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") } // Top Reactions @@ -326,6 +329,36 @@ func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Reques 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 = err + return + } + + startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + + topDMs, err := c.App.GetTopDMsForUserSince(c.AppContext.Session().UserId, &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 + } + + w.Write(js) +} + // postCountByDurationViewModel expects a list of channels that are pre-authorized for the given user to view. func postCountByDurationViewModel(c *Context, topChannelList *model.TopChannelList, startTime *time.Time, timeRange string, userID *string, location *time.Location) (model.ChannelPostCountByDuration, *model.AppError) { if len(topChannelList.Items) == 0 { diff --git a/app/app_iface.go b/app/app_iface.go index e1e0aeb057..031ba54c36 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -791,6 +791,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 d31592efe5..c004dde2e2 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -9904,6 +9904,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 2379ee300f..1905ad95aa 100644 --- a/app/post.go +++ b/app/post.go @@ -1932,6 +1932,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 includeEmbedsAndImages(a *App, c request.CTX, topThreadList *model.TopThreadList, userID string) (*model.TopThreadList, error) { for _, topThread := range topThreadList.Items { topThread.Post = a.PreparePostForClientWithEmbedsAndImages(c, topThread.Post, false, false) diff --git a/model/insights.go b/model/insights.go index dbd1720bc7..76667e8916 100644 --- a/model/insights.go +++ b/model/insights.go @@ -97,6 +97,18 @@ type DurationPostCount struct { PostCount int `db:"postcount"` } +// Top DMs +type TopDM struct { + MessageCount int64 `json:"post_count"` + Participants string `json:"-"` + SecondParticipant string `json:"second_participant"` +} + +type TopDMList struct { + InsightsListData + Items []*TopDM `json:"items"` +} + func TimeRangeToNumberDays(timeRange string) int { var n int switch timeRange { @@ -243,3 +255,17 @@ 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} +} diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 85c4829f58..bb04863f55 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -6007,6 +6007,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 5094c41fcb..56c7345078 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -6806,6 +6806,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 af7b11ca01..f65986c1d5 100644 --- a/store/sqlstore/post_store.go +++ b/store/sqlstore/post_store.go @@ -2963,3 +2963,55 @@ 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("count(p.id) as MessageCount", aggregator).FromSelect(channelSelector, "vch"). + Join("ChannelMembers as cm on cm.ChannelId = vch.Id"). + Join("Posts as p on p.ChannelId = vch.Id"). + Where(sq.Gt{ + "p.UpdateAt": since, + }).GroupBy("vch.id"). + Limit(uint64(limit)). + Offset(uint64(offset)) + + topDMsBuilder = topDMsBuilder.OrderBy("MessageCount DESC").Limit(uint64(limit)).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 = postProcessTopDMs(userID, topDMs) + return model.GetTopDMListWithPagination(topDMs, limit), nil +} + +func postProcessTopDMs(userID string, topDMs []*model.TopDM) []*model.TopDM { + for _, topDM := range topDMs { + participants := strings.Split(topDM.Participants, ",") + if participants[0] == userID { + topDM.SecondParticipant = participants[1] + } else { + topDM.SecondParticipant = participants[0] + } + } + return topDMs +} diff --git a/store/store.go b/store/store.go index 370dad74dc..fbd2ed3f1a 100644 --- a/store/store.go +++ b/store/store.go @@ -388,6 +388,9 @@ type PostStore interface { GetPostsSinceForSync(options model.GetPostsSinceForSyncOptions, cursor model.GetPostsSinceForSyncCursor, limit int) ([]*model.Post, model.GetPostsSinceForSyncCursor, 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 0f24d48db0..bc430ad0f0 100644 --- a/store/storetest/mocks/PostStore.go +++ b/store/storetest/mocks/PostStore.go @@ -700,6 +700,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/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index eb920da051..16d0dbf0d1 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -5434,6 +5434,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()