diff --git a/api4/user.go b/api4/user.go index eebd229392..bd652b8590 100644 --- a/api4/user.go +++ b/api4/user.go @@ -95,6 +95,7 @@ func (api *API) InitUser() { api.BaseRoutes.UserThreads.Handle("", api.ApiSessionRequired(getThreadsForUser)).Methods("GET") api.BaseRoutes.UserThreads.Handle("/read", api.ApiSessionRequired(updateReadStateAllThreadsByUser)).Methods("PUT") + api.BaseRoutes.UserThread.Handle("", api.ApiSessionRequired(getThreadForUser)).Methods("GET") api.BaseRoutes.UserThread.Handle("/following", api.ApiSessionRequired(followThreadByUser)).Methods("PUT") api.BaseRoutes.UserThread.Handle("/following", api.ApiSessionRequired(unfollowThreadByUser)).Methods("DELETE") api.BaseRoutes.UserThread.Handle("/read/{timestamp:[0-9]+}", api.ApiSessionRequired(updateReadStateThreadByUser)).Methods("PUT") @@ -2820,6 +2821,27 @@ func migrateAuthToSaml(c *Context, w http.ResponseWriter, r *http.Request) { ReturnStatusOK(w) } +func getThreadForUser(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId().RequireTeamId().RequireThreadId() + if c.Err != nil { + return + } + if !c.App.SessionHasPermissionToUser(*c.App.Session(), c.Params.UserId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + extendedStr := r.URL.Query().Get("extended") + + extended, _ := strconv.ParseBool(extendedStr) + threads, err := c.App.GetThreadForUser(c.Params.UserId, c.Params.TeamId, c.Params.ThreadId, extended) + if err != nil { + c.Err = err + return + } + + w.Write([]byte(threads.ToJson())) +} + func getThreadsForUser(c *Context, w http.ResponseWriter, r *http.Request) { c.RequireUserId().RequireTeamId() if c.Err != nil { diff --git a/api4/user_test.go b/api4/user_test.go index 0a18c8828e..2a10523157 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -5668,6 +5668,45 @@ func TestThreadCounts(t *testing.T) { }) } +func TestSingleThreadGet(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + os.Setenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS") + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.ThreadAutoFollow = true + *cfg.ServiceSettings.CollapsedThreads = model.COLLAPSED_THREADS_DEFAULT_ON + }) + + Client := th.Client + defer th.App.Srv().Store.Post().PermanentDeleteByUser(th.BasicUser.Id) + defer th.App.Srv().Store.Post().PermanentDeleteByUser(th.SystemAdminUser.Id) + + // create a post by regular user + rpost, _ := postAndCheck(t, Client, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testMsg"}) + // reply with another + time.Sleep(1) + postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply", RootId: rpost.Id}) + + // create another thread to check that we are not returning it by mistake + rpost2, _ := postAndCheck(t, Client, &model.Post{ChannelId: th.BasicChannel2.Id, Message: "testMsg2"}) + time.Sleep(1) + postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel2.Id, Message: "testReply", RootId: rpost2.Id}) + + // regular user should have two threads with 3 replies total + threads, _ := checkThreadListReplies(t, th, th.Client, th.BasicUser.Id, 2, 2, nil) + + tr, resp := th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, false) + CheckNoError(t, resp) + require.NotNil(t, tr) + require.Equal(t, threads.Threads[0].PostId, tr.PostId) + require.Empty(t, tr.Participants[0].Username) + + tr, resp = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) + CheckNoError(t, resp) + require.NotEmpty(t, tr.Participants[0].Username) +} + func TestMaintainUnreadMentionsInThread(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/app/app_iface.go b/app/app_iface.go index 21d15f56c2..f6573e6db3 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -693,6 +693,7 @@ type AppIface interface { GetTeamsForUser(userId string) ([]*model.Team, *model.AppError) GetTeamsUnreadForUser(excludeTeamId string, userId string) ([]*model.TeamUnread, *model.AppError) GetTermsOfService(id string) (*model.TermsOfService, *model.AppError) + GetThreadForUser(userId, teamId, threadId string, extended bool) (*model.ThreadResponse, *model.AppError) GetThreadMembershipsForUser(userId, teamId string) ([]*model.ThreadMembership, error) GetThreadsForUser(userId, teamId string, options model.GetUserThreadsOpts) (*model.Threads, *model.AppError) GetUploadSession(uploadId string) (*model.UploadSession, *model.AppError) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 6741bfbca1..4dd0960c9f 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -8529,6 +8529,28 @@ func (a *OpenTracingAppLayer) GetTermsOfService(id string) (*model.TermsOfServic return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetThreadForUser(userId string, teamId string, threadId string, extended bool) (*model.ThreadResponse, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetThreadForUser") + + 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.GetThreadForUser(userId, teamId, threadId, extended) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetThreadMembershipsForUser(userId string, teamId string) ([]*model.ThreadMembership, error) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetThreadMembershipsForUser") diff --git a/app/user.go b/app/user.go index 6095dfb068..c5aacb16e3 100644 --- a/app/user.go +++ b/app/user.go @@ -2371,6 +2371,19 @@ func (a *App) GetThreadsForUser(userId, teamId string, options model.GetUserThre return threads, nil } +func (a *App) GetThreadForUser(userId, teamId, threadId string, extended bool) (*model.ThreadResponse, *model.AppError) { + thread, err := a.Srv().Store.Thread().GetThreadForUser(userId, teamId, threadId, extended) + if err != nil { + return nil, model.NewAppError("GetThreadForUser", "app.user.get_threads_for_user.app_error", nil, err.Error(), http.StatusInternalServerError) + } + if thread == nil { + return nil, model.NewAppError("GetThreadForUser", "app.user.get_threads_for_user.not_found", nil, "thread not found/followed", http.StatusNotFound) + } + a.sanitizeProfiles(thread.Participants, false) + thread.Post.SanitizeProps() + return thread, nil +} + func (a *App) UpdateThreadsReadForUser(userId, teamId string) *model.AppError { nErr := a.Srv().Store.Thread().MarkAllAsRead(userId, teamId) if nErr != nil { diff --git a/i18n/en.json b/i18n/en.json index 81f78de859..61edfae2fb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -5626,6 +5626,10 @@ "id": "app.user.get_threads_for_user.app_error", "translation": "Unable to get user threads" }, + { + "id": "app.user.get_threads_for_user.not_found", + "translation": "User thread doesn't exist or is not followed" + }, { "id": "app.user.get_total_users_count.app_error", "translation": "We could not count the users." diff --git a/model/client4.go b/model/client4.go index 48216fb837..89c60115b5 100644 --- a/model/client4.go +++ b/model/client4.go @@ -5824,6 +5824,23 @@ func (c *Client4) GetUserThreads(userId, teamId string, options GetUserThreadsOp return &threads, BuildResponse(r) } +func (c *Client4) GetUserThread(userId, teamId, threadId string, extended bool) (*ThreadResponse, *Response) { + url := c.GetUserThreadRoute(userId, teamId, threadId) + if extended { + url += "?extended=true" + } + r, appErr := c.DoApiGet(url, "") + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + + var thread ThreadResponse + json.NewDecoder(r.Body).Decode(&thread) + + return &thread, BuildResponse(r) +} + func (c *Client4) UpdateThreadsReadForUser(userId, teamId string) *Response { r, appErr := c.DoApiPut(fmt.Sprintf("%s/read", c.GetUserThreadsRoute(userId, teamId)), "") if appErr != nil { diff --git a/model/thread.go b/model/thread.go index 02c50b9a13..a1a94842c1 100644 --- a/model/thread.go +++ b/model/thread.go @@ -50,6 +50,11 @@ type GetUserThreadsOpts struct { Since uint64 } +func (o *ThreadResponse) ToJson() string { + b, _ := json.Marshal(o) + return string(b) +} + func (o *Threads) ToJson() string { b, _ := json.Marshal(o) return string(b) diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 0b579ab5ac..ab135cf91d 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -7828,6 +7828,24 @@ func (s *OpenTracingLayerThreadStore) GetPosts(threadId string, since int64) ([] return result, err } +func (s *OpenTracingLayerThreadStore) GetThreadForUser(userId string, teamId string, threadId string, extended bool) (*model.ThreadResponse, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.GetThreadForUser") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ThreadStore.GetThreadForUser(userId, teamId, threadId, extended) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerThreadStore) GetThreadsForUser(userId string, teamId string, opts model.GetUserThreadsOpts) (*model.Threads, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.GetThreadsForUser") diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index a73e1a7d0f..2fd667a110 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -8498,6 +8498,26 @@ func (s *RetryLayerThreadStore) GetPosts(threadId string, since int64) ([]*model } +func (s *RetryLayerThreadStore) GetThreadForUser(userId string, teamId string, threadId string, extended bool) (*model.ThreadResponse, error) { + + tries := 0 + for { + result, err := s.ThreadStore.GetThreadForUser(userId, teamId, threadId, extended) + 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 + } + } + +} + func (s *RetryLayerThreadStore) GetThreadsForUser(userId string, teamId string, opts model.GetUserThreadsOpts) (*model.Threads, error) { tries := 0 diff --git a/store/sqlstore/thread_store.go b/store/sqlstore/thread_store.go index a60dc179be..7349cdd1b6 100644 --- a/store/sqlstore/thread_store.go +++ b/store/sqlstore/thread_store.go @@ -283,6 +283,71 @@ func (s *SqlThreadStore) GetThreadsForUser(userId, teamId string, opts model.Get return result, nil } +func (s *SqlThreadStore) GetThreadForUser(userId, teamId, threadId string, extended bool) (*model.ThreadResponse, error) { + type JoinedThread struct { + PostId string + Following bool + ReplyCount int64 + LastReplyAt int64 + LastViewedAt int64 + UnreadReplies int64 + UnreadMentions int64 + Participants model.StringArray + model.Post + } + + unreadRepliesQuery := "SELECT COUNT(Posts.Id) From Posts Where Posts.RootId=ThreadMemberships.PostId AND Posts.UpdateAt >= ThreadMemberships.LastViewed AND Posts.DeleteAt=0" + fetchConditions := sq.And{ + sq.Or{sq.Eq{"Channels.TeamId": teamId}, sq.Eq{"Channels.TeamId": ""}}, + sq.Eq{"ThreadMemberships.UserId": userId}, + sq.Eq{"Threads.PostId": threadId}, + } + + var thread JoinedThread + query, args, _ := s.getQueryBuilder(). + Select("Threads.*, Posts.*, ThreadMemberships.LastViewed as LastViewedAt, ThreadMemberships.UnreadMentions as UnreadMentions, ThreadMemberships.Following"). + From("Threads"). + Column(sq.Alias(sq.Expr(unreadRepliesQuery), "UnreadReplies")). + LeftJoin("Posts ON Posts.Id = Threads.PostId"). + LeftJoin("Channels ON Posts.ChannelId = Channels.Id"). + LeftJoin("ThreadMemberships ON ThreadMemberships.PostId = Threads.PostId"). + Where(fetchConditions).ToSql() + err := s.GetReplica().SelectOne(&thread, query, args...) + + if err != nil { + return nil, err + } + + if !thread.Following { + return nil, nil // in case the thread is not followed anymore - return nil error to be interpreted as 404 + } + + var users []*model.User + if extended { + var err error + users, err = s.User().GetProfileByIds(thread.Participants, &store.UserGetByIdsOpts{}, true) + if err != nil { + return nil, errors.Wrapf(err, "failed to get threads for user id=%s", userId) + } + } else { + for _, userId := range thread.Participants { + users = append(users, &model.User{Id: userId}) + } + } + + result := &model.ThreadResponse{ + PostId: thread.PostId, + ReplyCount: thread.ReplyCount, + LastReplyAt: thread.LastReplyAt, + LastViewedAt: thread.LastViewedAt, + UnreadReplies: thread.UnreadReplies, + UnreadMentions: thread.UnreadMentions, + Participants: users, + Post: &thread.Post, + } + + return result, nil +} func (s *SqlThreadStore) MarkAllAsRead(userId, teamId string) error { memberships, err := s.GetMembershipsForUser(userId, teamId) diff --git a/store/store.go b/store/store.go index 85895adea7..fea9404e38 100644 --- a/store/store.go +++ b/store/store.go @@ -252,6 +252,7 @@ type ThreadStore interface { Update(thread *model.Thread) (*model.Thread, error) Get(id string) (*model.Thread, error) GetThreadsForUser(userId, teamId string, opts model.GetUserThreadsOpts) (*model.Threads, error) + GetThreadForUser(userId, teamId, threadId string, extended bool) (*model.ThreadResponse, error) Delete(postId string) error GetPosts(threadId string, since int64) ([]*model.Post, error) diff --git a/store/storetest/mocks/ThreadStore.go b/store/storetest/mocks/ThreadStore.go index 3d0fd2592b..6399c08b29 100644 --- a/store/storetest/mocks/ThreadStore.go +++ b/store/storetest/mocks/ThreadStore.go @@ -171,6 +171,29 @@ func (_m *ThreadStore) GetPosts(threadId string, since int64) ([]*model.Post, er return r0, r1 } +// GetThreadForUser provides a mock function with given fields: userId, teamId, threadId, extended +func (_m *ThreadStore) GetThreadForUser(userId string, teamId string, threadId string, extended bool) (*model.ThreadResponse, error) { + ret := _m.Called(userId, teamId, threadId, extended) + + var r0 *model.ThreadResponse + if rf, ok := ret.Get(0).(func(string, string, string, bool) *model.ThreadResponse); ok { + r0 = rf(userId, teamId, threadId, extended) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ThreadResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, string, bool) error); ok { + r1 = rf(userId, teamId, threadId, extended) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetThreadsForUser provides a mock function with given fields: userId, teamId, opts func (_m *ThreadStore) GetThreadsForUser(userId string, teamId string, opts model.GetUserThreadsOpts) (*model.Threads, error) { ret := _m.Called(userId, teamId, opts) diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index d2773461f2..ddaa4f3092 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -7064,6 +7064,22 @@ func (s *TimerLayerThreadStore) GetPosts(threadId string, since int64) ([]*model return result, err } +func (s *TimerLayerThreadStore) GetThreadForUser(userId string, teamId string, threadId string, extended bool) (*model.ThreadResponse, error) { + start := timemodule.Now() + + result, err := s.ThreadStore.GetThreadForUser(userId, teamId, threadId, extended) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ThreadStore.GetThreadForUser", success, elapsed) + } + return result, err +} + func (s *TimerLayerThreadStore) GetThreadsForUser(userId string, teamId string, opts model.GetUserThreadsOpts) (*model.Threads, error) { start := timemodule.Now()