diff --git a/api4/api.go b/api4/api.go index bbf22a3f73..fd45d722bb 100644 --- a/api4/api.go +++ b/api4/api.go @@ -46,6 +46,7 @@ type Routes struct { ChannelMember *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/members/{user_id:[A-Za-z0-9]+}' ChannelMembersForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}/channels/members' ChannelModerations *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/moderations' + ChannelCategories *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}/channels/categories' Posts *mux.Router // 'api/v4/posts' Post *mux.Router // 'api/v4/posts/{post_id:[A-Za-z0-9]+}' @@ -160,6 +161,7 @@ func Init(configservice configservice.ConfigService, globalOptionsFunc app.AppOp api.BaseRoutes.ChannelMember = api.BaseRoutes.ChannelMembers.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter() api.BaseRoutes.ChannelMembersForUser = api.BaseRoutes.User.PathPrefix("/teams/{team_id:[A-Za-z0-9]+}/channels/members").Subrouter() api.BaseRoutes.ChannelModerations = api.BaseRoutes.Channel.PathPrefix("/moderations").Subrouter() + api.BaseRoutes.ChannelCategories = api.BaseRoutes.User.PathPrefix("/teams/{team_id:[A-Za-z0-9]+}/channels/categories").Subrouter() api.BaseRoutes.Posts = api.BaseRoutes.ApiRoot.PathPrefix("/posts").Subrouter() api.BaseRoutes.Post = api.BaseRoutes.Posts.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter() diff --git a/api4/channel.go b/api4/channel.go index e662698959..628ede38e8 100644 --- a/api4/channel.go +++ b/api4/channel.go @@ -35,6 +35,15 @@ func (api *API) InitChannel() { api.BaseRoutes.ChannelsForTeam.Handle("/search_autocomplete", api.ApiSessionRequired(autocompleteChannelsForTeamForSearch)).Methods("GET") api.BaseRoutes.User.Handle("/teams/{team_id:[A-Za-z0-9]+}/channels", api.ApiSessionRequired(getChannelsForTeamForUser)).Methods("GET") + api.BaseRoutes.ChannelCategories.Handle("", api.ApiSessionRequired(getCategoriesForTeamForUser)).Methods("GET") + api.BaseRoutes.ChannelCategories.Handle("", api.ApiSessionRequired(createCategoryForTeamForUser)).Methods("POST") + api.BaseRoutes.ChannelCategories.Handle("", api.ApiSessionRequired(updateCategoriesForTeamForUser)).Methods("PUT") + api.BaseRoutes.ChannelCategories.Handle("/order", api.ApiSessionRequired(getCategoryOrderForTeamForUser)).Methods("GET") + api.BaseRoutes.ChannelCategories.Handle("/order", api.ApiSessionRequired(updateCategoryOrderForTeamForUser)).Methods("PUT") + api.BaseRoutes.ChannelCategories.Handle("/{category_id:[A-Za-z0-9]+}", api.ApiSessionRequired(getCategoryForTeamForUser)).Methods("GET") + api.BaseRoutes.ChannelCategories.Handle("/{category_id:[A-Za-z0-9]+}", api.ApiSessionRequired(updateCategoryForTeamForUser)).Methods("PUT") + api.BaseRoutes.ChannelCategories.Handle("/{category_id:[A-Za-z0-9]+}", api.ApiSessionRequired(deleteCategoryForTeamForUser)).Methods("DELETE") + api.BaseRoutes.Channel.Handle("", api.ApiSessionRequired(getChannel)).Methods("GET") api.BaseRoutes.Channel.Handle("", api.ApiSessionRequired(updateChannel)).Methods("PUT") api.BaseRoutes.Channel.Handle("/patch", api.ApiSessionRequired(patchChannel)).Methods("PUT") @@ -1848,3 +1857,254 @@ func moveChannel(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(channel.ToJson())) } + +func getCategoriesForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId().RequireTeamId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToUser(*c.App.Session(), c.Params.UserId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + categories, err := c.App.GetSidebarCategories(c.Params.UserId, c.Params.TeamId) + if err != nil { + c.Err = err + return + } + + w.Write(categories.ToJson()) +} + +func createCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId().RequireTeamId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToUser(*c.App.Session(), c.Params.UserId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + auditRec := c.MakeAuditRecord("createCategoryForTeamForUser", audit.Fail) + defer c.LogAuditRec(auditRec) + + categoryCreateRequest, err := model.SidebarCategoryFromJson(r.Body) + if err != nil || c.Params.UserId != categoryCreateRequest.UserId || c.Params.TeamId != categoryCreateRequest.TeamId { + c.SetInvalidParam("category") + return + } + if appErr := validateUserChannels("createCategoryForTeamForUser", c, c.Params.TeamId, c.Params.UserId, categoryCreateRequest.Channels); appErr != nil { + c.Err = appErr + return + } + category, appErr := c.App.CreateSidebarCategory(c.Params.UserId, c.Params.TeamId, categoryCreateRequest) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + w.Write(category.ToJson()) +} + +func getCategoryOrderForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId().RequireTeamId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToUser(*c.App.Session(), c.Params.UserId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + order, err := c.App.GetSidebarCategoryOrder(c.Params.UserId, c.Params.TeamId) + if err != nil { + c.Err = err + return + } + + w.Write([]byte(model.ArrayToJson(order))) +} + +func updateCategoryOrderForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId().RequireTeamId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToUser(*c.App.Session(), c.Params.UserId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + auditRec := c.MakeAuditRecord("updateCategoryOrderForTeamForUser", audit.Fail) + defer c.LogAuditRec(auditRec) + + categoryOrder := model.ArrayFromJson(r.Body) + + for _, categoryId := range categoryOrder { + if !c.App.SessionHasPermissionToCategory(*c.App.Session(), c.Params.UserId, c.Params.TeamId, categoryId) { + c.SetInvalidParam("category") + return + } + } + + err := c.App.UpdateSidebarCategoryOrder(c.Params.UserId, c.Params.TeamId, categoryOrder) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + w.Write([]byte(model.ArrayToJson(categoryOrder))) +} + +func getCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId().RequireTeamId().RequireCategoryId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToCategory(*c.App.Session(), c.Params.UserId, c.Params.TeamId, c.Params.CategoryId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + categories, err := c.App.GetSidebarCategory(c.Params.CategoryId) + if err != nil { + c.Err = err + return + } + + w.Write(categories.ToJson()) +} + +func updateCategoriesForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId().RequireTeamId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToUser(*c.App.Session(), c.Params.UserId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + auditRec := c.MakeAuditRecord("updateCategoriesForTeamForUser", audit.Fail) + defer c.LogAuditRec(auditRec) + + categoriesUpdateRequest, err := model.SidebarCategoriesFromJson(r.Body) + if err != nil { + c.SetInvalidParam("category") + return + } + var channelsToCheck []string + for _, category := range categoriesUpdateRequest { + if !c.App.SessionHasPermissionToCategory(*c.App.Session(), c.Params.UserId, c.Params.TeamId, category.Id) { + c.SetInvalidParam("category") + return + } + channelsToCheck = append(channelsToCheck, category.Channels...) + } + if appErr := validateUserChannels("updateCategoriesForTeamForUser", c, c.Params.TeamId, c.Params.UserId, channelsToCheck); appErr != nil { + c.Err = appErr + return + } + + categories, appErr := c.App.UpdateSidebarCategories(c.Params.UserId, c.Params.TeamId, categoriesUpdateRequest) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + w.Write(model.SidebarCategoriesWithChannelsToJson(categories)) +} + +func validateUserChannels(operationName string, c *Context, teamId, userId string, channelIDs []string) *model.AppError { + channels, err := c.App.GetChannelsForUser(teamId, userId, false) + if err != nil { + return model.NewAppError("Api4."+operationName, "api.invalid_channel", nil, err.Error(), http.StatusBadRequest) + } + + for _, channelId := range channelIDs { + found := false + for _, channel := range *channels { + if channel.Id == channelId { + found = true + break + } + } + + if !found { + return model.NewAppError("Api4."+operationName, "api.invalid_channel", nil, "", http.StatusBadRequest) + } + } + + return nil +} + +func updateCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId().RequireTeamId().RequireCategoryId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToCategory(*c.App.Session(), c.Params.UserId, c.Params.TeamId, c.Params.CategoryId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + auditRec := c.MakeAuditRecord("updateCategoryForTeamForUser", audit.Fail) + defer c.LogAuditRec(auditRec) + + categoryUpdateRequest, err := model.SidebarCategoryFromJson(r.Body) + if err != nil || categoryUpdateRequest.TeamId != c.Params.TeamId || categoryUpdateRequest.UserId != c.Params.UserId { + c.SetInvalidParam("category") + return + } + + if appErr := validateUserChannels("updateCategoryForTeamForUser", c, c.Params.TeamId, c.Params.UserId, categoryUpdateRequest.Channels); appErr != nil { + c.Err = appErr + return + } + categoryUpdateRequest.Id = c.Params.CategoryId + + categories, appErr := c.App.UpdateSidebarCategories(c.Params.UserId, c.Params.TeamId, []*model.SidebarCategoryWithChannels{categoryUpdateRequest}) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + w.Write(categories[0].ToJson()) +} + +func deleteCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId().RequireTeamId().RequireCategoryId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToCategory(*c.App.Session(), c.Params.UserId, c.Params.TeamId, c.Params.CategoryId) { + c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) + return + } + + auditRec := c.MakeAuditRecord("deleteCategoryForTeamForUser", audit.Fail) + defer c.LogAuditRec(auditRec) + + appErr := c.App.DeleteSidebarCategory(c.Params.UserId, c.Params.TeamId, c.Params.CategoryId) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + ReturnStatusOK(w) +} diff --git a/api4/channel_test.go b/api4/channel_test.go index b6ce78f0f4..89bd596d50 100644 --- a/api4/channel_test.go +++ b/api4/channel_test.go @@ -3888,5 +3888,106 @@ func TestMoveChannel(t *testing.T) { require.NotNil(t, resp.Error) CheckErrorMessage(t, resp, "app.channel.move_channel.members_do_not_match.error") }) - +} + +func TestUpdateCategoryForTeamForUser(t *testing.T) { + t.Run("should update the channel order of the Channels category", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + categories, resp := th.Client.GetSidebarCategoriesForTeamForUser(th.BasicUser.Id, th.BasicTeam.Id, "") + require.Nil(t, resp.Error) + require.Len(t, categories.Categories, 3) + require.Len(t, categories.Order, 3) + + channelsCategory := categories.Categories[1] + require.Equal(t, model.SidebarCategoryChannels, channelsCategory.Type) + require.Len(t, channelsCategory.Channels, 5) // Town Square, Off Topic, and the 3 channels created by InitBasic + + // Should return the correct values from the API + updatedCategory := &model.SidebarCategoryWithChannels{ + SidebarCategory: channelsCategory.SidebarCategory, + Channels: []string{channelsCategory.Channels[1], channelsCategory.Channels[0], channelsCategory.Channels[4], channelsCategory.Channels[3], channelsCategory.Channels[2]}, + } + + t.Log("UserId=" + th.BasicUser.Id) + t.Log("TeamId=" + th.BasicTeam.Id) + t.Log("category=" + channelsCategory.Id) + + received, resp := th.Client.UpdateSidebarCategoryForTeamForUser(th.BasicUser.Id, th.BasicTeam.Id, channelsCategory.Id, updatedCategory) + assert.Nil(t, resp.Error) + assert.Equal(t, channelsCategory.Id, received.Id) + assert.Equal(t, updatedCategory.Channels, received.Channels) + + // And when requesting the category later + received, resp = th.Client.GetSidebarCategoryForTeamForUser(th.BasicUser.Id, th.BasicTeam.Id, channelsCategory.Id, "") + assert.Nil(t, resp.Error) + assert.Equal(t, channelsCategory.Id, received.Id) + assert.Equal(t, updatedCategory.Channels, received.Channels) + }) + + t.Run("should update the sort order of the DM category", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + categories, resp := th.Client.GetSidebarCategoriesForTeamForUser(th.BasicUser.Id, th.BasicTeam.Id, "") + require.Nil(t, resp.Error) + require.Len(t, categories.Categories, 3) + require.Len(t, categories.Order, 3) + + dmsCategory := categories.Categories[2] + require.Equal(t, model.SidebarCategoryDirectMessages, dmsCategory.Type) + require.Equal(t, model.SidebarCategorySortRecent, dmsCategory.Sorting) + + // Should return the correct values from the API + updatedCategory := &model.SidebarCategoryWithChannels{ + SidebarCategory: dmsCategory.SidebarCategory, + Channels: dmsCategory.Channels, + } + updatedCategory.Sorting = model.SidebarCategorySortAlphabetical + + received, resp := th.Client.UpdateSidebarCategoryForTeamForUser(th.BasicUser.Id, th.BasicTeam.Id, dmsCategory.Id, updatedCategory) + assert.Nil(t, resp.Error) + assert.Equal(t, dmsCategory.Id, received.Id) + assert.Equal(t, model.SidebarCategorySortAlphabetical, received.Sorting) + + // And when requesting the category later + received, resp = th.Client.GetSidebarCategoryForTeamForUser(th.BasicUser.Id, th.BasicTeam.Id, dmsCategory.Id, "") + assert.Nil(t, resp.Error) + assert.Equal(t, dmsCategory.Id, received.Id) + assert.Equal(t, model.SidebarCategorySortAlphabetical, received.Sorting) + }) + + t.Run("should update the display name of a custom category", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + customCategory, resp := th.Client.CreateSidebarCategoryForTeamForUser(th.BasicUser.Id, th.BasicTeam.Id, &model.SidebarCategoryWithChannels{ + SidebarCategory: model.SidebarCategory{ + UserId: th.BasicUser.Id, + TeamId: th.BasicTeam.Id, + DisplayName: "custom123", + }, + }) + require.Nil(t, resp.Error) + require.Equal(t, "custom123", customCategory.DisplayName) + + // Should return the correct values from the API + updatedCategory := &model.SidebarCategoryWithChannels{ + SidebarCategory: customCategory.SidebarCategory, + Channels: customCategory.Channels, + } + updatedCategory.DisplayName = "abcCustom" + + received, resp := th.Client.UpdateSidebarCategoryForTeamForUser(th.BasicUser.Id, th.BasicTeam.Id, customCategory.Id, updatedCategory) + assert.Nil(t, resp.Error) + assert.Equal(t, customCategory.Id, received.Id) + assert.Equal(t, updatedCategory.DisplayName, received.DisplayName) + + // And when requesting the category later + received, resp = th.Client.GetSidebarCategoryForTeamForUser(th.BasicUser.Id, th.BasicTeam.Id, customCategory.Id, "") + assert.Nil(t, resp.Error) + assert.Equal(t, customCategory.Id, received.Id) + assert.Equal(t, updatedCategory.DisplayName, received.DisplayName) + }) } diff --git a/app/app_iface.go b/app/app_iface.go index aaeaf5d989..d8c2858291 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -415,6 +415,7 @@ type AppIface interface { CreateRole(role *model.Role) (*model.Role, *model.AppError) CreateScheme(scheme *model.Scheme) (*model.Scheme, *model.AppError) CreateSession(session *model.Session) (*model.Session, *model.AppError) + CreateSidebarCategory(userId, teamId string, newCategory *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) CreateTeam(team *model.Team) (*model.Team, *model.AppError) CreateTeamWithUser(team *model.Team, userId string) (*model.Team, *model.AppError) CreateTermsOfService(text, userId string) (*model.TermsOfService, *model.AppError) @@ -449,6 +450,7 @@ type AppIface interface { DeletePreferences(userId string, preferences model.Preferences) *model.AppError DeleteReactionForPost(reaction *model.Reaction) *model.AppError DeleteScheme(schemeId string) (*model.Scheme, *model.AppError) + DeleteSidebarCategory(userId, teamId, categoryId string) *model.AppError DeleteToken(token *model.Token) *model.AppError DiagnosticId() string DisableAutoResponder(userId string, asAdmin bool) *model.AppError @@ -633,6 +635,9 @@ type AppIface interface { GetSession(token string) (*model.Session, *model.AppError) GetSessionById(sessionId string) (*model.Session, *model.AppError) GetSessions(userId string) ([]*model.Session, *model.AppError) + GetSidebarCategories(userId, teamId string) (*model.OrderedSidebarCategories, *model.AppError) + GetSidebarCategory(categoryId string) (*model.SidebarCategoryWithChannels, *model.AppError) + GetSidebarCategoryOrder(userId, teamId string) ([]string, *model.AppError) GetSinglePost(postId string) (*model.Post, *model.AppError) GetSiteURL() string GetStatus(userId string) (*model.Status, *model.AppError) @@ -861,6 +866,7 @@ type AppIface interface { Session() *model.Session SessionCacheLength() int SessionHasPermissionTo(session model.Session, permission *model.Permission) bool + SessionHasPermissionToCategory(session model.Session, userId, teamId, categoryId string) bool SessionHasPermissionToChannel(session model.Session, channelId string, permission *model.Permission) bool SessionHasPermissionToChannelByPost(session model.Session, postId string, permission *model.Permission) bool SessionHasPermissionToTeam(session model.Session, teamId string, permission *model.Permission) bool @@ -953,6 +959,8 @@ type AppIface interface { UpdateRole(role *model.Role) (*model.Role, *model.AppError) UpdateScheme(scheme *model.Scheme) (*model.Scheme, *model.AppError) UpdateSessionsIsGuest(userId string, isGuest bool) + UpdateSidebarCategories(userId, teamId string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) + UpdateSidebarCategoryOrder(userId, teamId string, categoryOrder []string) *model.AppError UpdateTeam(team *model.Team) (*model.Team, *model.AppError) UpdateTeamMemberRoles(teamId string, userId string, newRoles string) (*model.TeamMember, *model.AppError) UpdateTeamMemberSchemeRoles(teamId string, userId string, isSchemeGuest bool, isSchemeUser bool, isSchemeAdmin bool) (*model.TeamMember, *model.AppError) diff --git a/app/authorization.go b/app/authorization.go index 56cd02cb8e..63c5721460 100644 --- a/app/authorization.go +++ b/app/authorization.go @@ -89,6 +89,14 @@ func (a *App) SessionHasPermissionToChannelByPost(session model.Session, postId return a.SessionHasPermissionTo(session, permission) } +func (a *App) SessionHasPermissionToCategory(session model.Session, userId, teamId, categoryId string) bool { + if a.SessionHasPermissionTo(session, model.PERMISSION_EDIT_OTHER_USERS) { + return true + } + category, err := a.GetSidebarCategory(categoryId) + return err == nil && category != nil && category.UserId == session.UserId && category.UserId == userId && category.TeamId == teamId +} + func (a *App) SessionHasPermissionToUser(session model.Session, userId string) bool { if userId == "" { return false diff --git a/app/channel.go b/app/channel.go index 712278d990..91a7449680 100644 --- a/app/channel.go +++ b/app/channel.go @@ -2383,6 +2383,10 @@ func (a *App) MoveChannel(team *model.Team, channel *model.Channel, user *model. return err } + if appErr := a.Srv().Store.Channel().UpdateSidebarChannelCategoryOnMove(channel, team.Id); appErr != nil { + return appErr + } + channel.TeamId = team.Id if _, err := a.Srv().Store.Channel().Update(channel); err != nil { var appErr *model.AppError @@ -2598,3 +2602,70 @@ func (a *App) ClearChannelMembersCache(channelID string) { page++ } } + +func (a *App) createInitialSidebarCategories(user *model.User, team *model.Team) *model.AppError { + nErr := a.Srv().Store.Channel().CreateInitialSidebarCategories(user, team.Id) + + if nErr != nil { + return model.NewAppError("createInitialSidebarCategories", "app.channel.create_initial_sidebar_categories.internal_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + return nil +} + +func (a *App) GetSidebarCategories(userId, teamId string) (*model.OrderedSidebarCategories, *model.AppError) { + return a.Srv().Store.Channel().GetSidebarCategories(userId, teamId) +} + +func (a *App) GetSidebarCategoryOrder(userId, teamId string) ([]string, *model.AppError) { + return a.Srv().Store.Channel().GetSidebarCategoryOrder(userId, teamId) +} + +func (a *App) GetSidebarCategory(categoryId string) (*model.SidebarCategoryWithChannels, *model.AppError) { + return a.Srv().Store.Channel().GetSidebarCategory(categoryId) +} + +func (a *App) CreateSidebarCategory(userId, teamId string, newCategory *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) { + category, err := a.Srv().Store.Channel().CreateSidebarCategory(userId, teamId, newCategory) + if err != nil { + return nil, err + } + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_SIDEBAR_CATEGORY_CREATED, teamId, "", userId, nil) + message.Add("category_id", category.Id) + a.Publish(message) + return category, nil +} + +func (a *App) UpdateSidebarCategoryOrder(userId, teamId string, categoryOrder []string) *model.AppError { + err := a.Srv().Store.Channel().UpdateSidebarCategoryOrder(userId, teamId, categoryOrder) + if err != nil { + return err + } + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_SIDEBAR_CATEGORY_ORDER_UPDATED, teamId, "", userId, nil) + message.Add("order", categoryOrder) + a.Publish(message) + return nil +} + +func (a *App) UpdateSidebarCategories(userId, teamId string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) { + result, err := a.Srv().Store.Channel().UpdateSidebarCategories(userId, teamId, categories) + if err != nil { + return nil, err + } + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_SIDEBAR_CATEGORY_UPDATED, teamId, "", userId, nil) + a.Publish(message) + return result, nil +} + +func (a *App) DeleteSidebarCategory(userId, teamId, categoryId string) *model.AppError { + err := a.Srv().Store.Channel().DeleteSidebarCategory(categoryId) + if err != nil { + return err + } + + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_SIDEBAR_CATEGORY_DELETED, teamId, "", userId, nil) + message.Add("category_id", categoryId) + a.Publish(message) + + return nil +} diff --git a/app/channel_test.go b/app/channel_test.go index c262ae6961..4519cb1804 100644 --- a/app/channel_test.go +++ b/app/channel_test.go @@ -1795,3 +1795,66 @@ func TestMarkChannelsAsViewedPanic(t *testing.T) { _, err := th.App.MarkChannelsAsViewed([]string{"channelID"}, "userID", th.App.Session().Id) require.Nil(t, err) } + +func TestSidebarCategory(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + basicChannel2 := th.CreateChannel(th.BasicTeam) + defer th.App.PermanentDeleteChannel(basicChannel2) + user := th.CreateUser() + defer th.App.Srv().Store.User().PermanentDelete(user.Id) + th.LinkUserToTeam(user, th.BasicTeam) + th.AddUserToChannel(user, basicChannel2) + + var createdCategory *model.SidebarCategoryWithChannels + t.Run("CreateSidebarCategory", func(t *testing.T) { + catData := model.SidebarCategoryWithChannels{ + SidebarCategory: model.SidebarCategory{ + DisplayName: "TEST", + }, + Channels: []string{th.BasicChannel.Id, basicChannel2.Id, basicChannel2.Id}, + } + _, err := th.App.CreateSidebarCategory(user.Id, th.BasicTeam.Id, &catData) + require.NotNil(t, err, "Should return error due to duplicate IDs") + catData.Channels = []string{th.BasicChannel.Id, basicChannel2.Id} + cat, err := th.App.CreateSidebarCategory(user.Id, th.BasicTeam.Id, &catData) + require.Nil(t, err, "Expected no error") + require.NotNil(t, cat, "Expected category object, got nil") + createdCategory = cat + }) + + t.Run("UpdateSidebarCategories", func(t *testing.T) { + require.NotNil(t, createdCategory) + createdCategory.Channels = []string{th.BasicChannel.Id} + updatedCat, err := th.App.UpdateSidebarCategories(user.Id, th.BasicTeam.Id, []*model.SidebarCategoryWithChannels{createdCategory}) + require.Nil(t, err, "Expected no error") + require.NotNil(t, updatedCat, "Expected category object, got nil") + require.Len(t, updatedCat, 1) + require.Len(t, updatedCat[0].Channels, 1) + require.Equal(t, updatedCat[0].Channels[0], th.BasicChannel.Id) + }) + + t.Run("UpdateSidebarCategoryOrder", func(t *testing.T) { + err := th.App.UpdateSidebarCategoryOrder(user.Id, th.BasicTeam.Id, []string{th.BasicChannel.Id, basicChannel2.Id}) + require.NotNil(t, err, "Should return error due to invalid order") + + actualOrder, err := th.App.GetSidebarCategoryOrder(user.Id, th.BasicTeam.Id) + require.Nil(t, err, "Should fetch order successfully") + + actualOrder[2], actualOrder[3] = actualOrder[3], actualOrder[2] + err = th.App.UpdateSidebarCategoryOrder(user.Id, th.BasicTeam.Id, actualOrder) + require.Nil(t, err, "Should update order successfully") + + actualOrder[2] = "asd" + err = th.App.UpdateSidebarCategoryOrder(user.Id, th.BasicTeam.Id, actualOrder) + require.NotNil(t, err, "Should return error due to invalid id") + }) + + t.Run("GetSidebarCategoryOrder", func(t *testing.T) { + catOrder, err := th.App.GetSidebarCategoryOrder(user.Id, th.BasicTeam.Id) + require.Nil(t, err, "Expected no error") + require.Len(t, catOrder, 4) + require.Equal(t, catOrder[1], createdCategory.Id, "the newly created category should be after favorites") + }) +} diff --git a/app/opentracing_layer.go b/app/opentracing_layer.go index db9e0ae825..10dd2a065e 100644 --- a/app/opentracing_layer.go +++ b/app/opentracing_layer.go @@ -2025,6 +2025,28 @@ func (a *OpenTracingAppLayer) CreateSession(session *model.Session) (*model.Sess return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) CreateSidebarCategory(userId string, teamId string, newCategory *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateSidebarCategory") + + 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.CreateSidebarCategory(userId, teamId, newCategory) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) CreateTeam(team *model.Team) (*model.Team, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateTeam") @@ -2857,6 +2879,28 @@ func (a *OpenTracingAppLayer) DeleteScheme(schemeId string) (*model.Scheme, *mod return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) DeleteSidebarCategory(userId string, teamId string, categoryId string) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteSidebarCategory") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.DeleteSidebarCategory(userId, teamId, categoryId) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) DeleteToken(token *model.Token) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteToken") @@ -7539,6 +7583,72 @@ func (a *OpenTracingAppLayer) GetSessions(userId string) ([]*model.Session, *mod return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetSidebarCategories(userId string, teamId string) (*model.OrderedSidebarCategories, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSidebarCategories") + + 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.GetSidebarCategories(userId, teamId) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetSidebarCategory(categoryId string) (*model.SidebarCategoryWithChannels, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSidebarCategory") + + 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.GetSidebarCategory(categoryId) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetSidebarCategoryOrder(userId string, teamId string) ([]string, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSidebarCategoryOrder") + + 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.GetSidebarCategoryOrder(userId, teamId) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetSinglePost(postId string) (*model.Post, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetSinglePost") @@ -12642,6 +12752,23 @@ func (a *OpenTracingAppLayer) SessionHasPermissionTo(session model.Session, perm return resultVar0 } +func (a *OpenTracingAppLayer) SessionHasPermissionToCategory(session model.Session, userId string, teamId string, categoryId string) bool { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SessionHasPermissionToCategory") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.SessionHasPermissionToCategory(session, userId, teamId, categoryId) + + return resultVar0 +} + func (a *OpenTracingAppLayer) SessionHasPermissionToChannel(session model.Session, channelId string, permission *model.Permission) bool { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SessionHasPermissionToChannel") @@ -14484,6 +14611,50 @@ func (a *OpenTracingAppLayer) UpdateSessionsIsGuest(userId string, isGuest bool) a.app.UpdateSessionsIsGuest(userId, isGuest) } +func (a *OpenTracingAppLayer) UpdateSidebarCategories(userId string, teamId string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateSidebarCategories") + + 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.UpdateSidebarCategories(userId, teamId, categories) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) UpdateSidebarCategoryOrder(userId string, teamId string, categoryOrder []string) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateSidebarCategoryOrder") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.UpdateSidebarCategoryOrder(userId, teamId, categoryOrder) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) UpdateTeam(team *model.Team) (*model.Team, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateTeam") diff --git a/app/preference.go b/app/preference.go index 6c85f90e0d..64ecfc0b6e 100644 --- a/app/preference.go +++ b/app/preference.go @@ -53,7 +53,13 @@ func (a *App) UpdatePreferences(userId string, preferences model.Preferences) *m return err } - message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PREFERENCES_CHANGED, "", "", userId, nil) + if err := a.Srv().Store.Channel().UpdateSidebarChannelsByPreferences(&preferences); err != nil { + return err + } + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_SIDEBAR_CATEGORY_UPDATED, "", "", userId, nil) + a.Publish(message) + + message = model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PREFERENCES_CHANGED, "", "", userId, nil) message.Add("preferences", preferences.ToJson()) a.Publish(message) diff --git a/app/team.go b/app/team.go index dd2c3aac4b..816b0f80a0 100644 --- a/app/team.go +++ b/app/team.go @@ -653,6 +653,15 @@ func (a *App) JoinUserToTeam(team *model.Team, user *model.User, userRequestorId return err } + if err := a.createInitialSidebarCategories(user, team); err != nil { + mlog.Error( + "Encountered an issue creating default sidebar categories.", + mlog.String("user_id", user.Id), + mlog.String("team_id", team.Id), + mlog.Err(err), + ) + } + shouldBeAdmin := team.Email == user.Email if !user.IsGuest() { @@ -969,6 +978,10 @@ func (a *App) RemoveTeamMemberFromTeam(teamMember *model.TeamMember, requestorId return err } + if err := a.Srv().Store.Channel().ClearSidebarOnTeamLeave(user.Id, teamMember.TeamId); err != nil { + return err + } + // delete the preferences that set the last channel used in the team and other team specific preferences if err := a.Srv().Store.Preference().DeleteCategory(user.Id, teamMember.TeamId); err != nil { return err @@ -1031,8 +1044,7 @@ func (a *App) LeaveTeam(team *model.Team, user *model.User, requestorId string) } } - err = a.RemoveTeamMemberFromTeam(teamMember, requestorId) - if err != nil { + if err := a.RemoveTeamMemberFromTeam(teamMember, requestorId); err != nil { return err } diff --git a/app/team_test.go b/app/team_test.go index 7640911022..986b0c733e 100644 --- a/app/team_test.go +++ b/app/team_test.go @@ -165,7 +165,21 @@ func TestAddUserToTeam(t *testing.T) { _, err = th.App.AddUserToTeam(th.BasicTeam.Id, ruser3.Id, "") require.NotNil(t, err, "Should not have allowed restricted user3") require.Equal(t, "JoinUserToTeam", err.Where, "Error should be JoinUserToTeam") + }) + t.Run("should set up initial sidebar categories when joining a team", func(t *testing.T) { + user := th.CreateUser() + team := th.CreateTeam() + + _, err := th.App.AddUserToTeam(team.Id, user.Id, "") + require.Nil(t, err) + + res, err := th.App.GetSidebarCategories(user.Id, team.Id) + require.Nil(t, err) + assert.Len(t, res.Categories, 3) + assert.Equal(t, model.SidebarCategoryFavorites, res.Categories[0].Type) + assert.Equal(t, model.SidebarCategoryChannels, res.Categories[1].Type) + assert.Equal(t, model.SidebarCategoryDirectMessages, res.Categories[2].Type) }) } @@ -390,6 +404,27 @@ func TestAddUserToTeamByToken(t *testing.T) { require.NotNil(t, err, "Should not add restricted user") require.Equal(t, "JoinUserToTeam", err.Where, "Error should be JoinUserToTeam") }) + + t.Run("should set up initial sidebar categories when joining a team by token", func(t *testing.T) { + user := th.CreateUser() + team := th.CreateTeam() + + token := model.NewToken( + TOKEN_TYPE_TEAM_INVITATION, + model.MapToJson(map[string]string{"teamId": team.Id}), + ) + require.Nil(t, th.App.Srv().Store.Token().Save(token)) + + _, err := th.App.AddUserToTeamByToken(user.Id, token.Token) + require.Nil(t, err) + + res, err := th.App.GetSidebarCategories(user.Id, team.Id) + require.Nil(t, err) + assert.Len(t, res.Categories, 3) + assert.Equal(t, model.SidebarCategoryFavorites, res.Categories[0].Type) + assert.Equal(t, model.SidebarCategoryChannels, res.Categories[1].Type) + assert.Equal(t, model.SidebarCategoryDirectMessages, res.Categories[2].Type) + }) } func TestAddUserToTeamByTeamId(t *testing.T) { diff --git a/i18n/en.json b/i18n/en.json index 1dbda73d70..1338bacd75 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1428,6 +1428,10 @@ "id": "api.incoming_webhook.invalid_username.app_error", "translation": "Invalid username." }, + { + "id": "api.invalid_channel", + "translation": "Channel listed in the request doesn't belong to the user" + }, { "id": "api.io_error", "translation": "input/output error" @@ -3030,6 +3034,10 @@ "id": "app.channel.create_direct_channel.internal_error", "translation": "Unable to save direct channel." }, + { + "id": "app.channel.create_initial_sidebar_categories.internal_error", + "translation": "Unable to create initial sidebar categories for user." + }, { "id": "app.channel.delete.app_error", "translation": "Unable to delete the channel." @@ -4862,6 +4870,14 @@ "id": "migrations.worker.run_migration.unknown_key", "translation": "Unable to run migration job due to unknown migration key." }, + { + "id": "migrations.worker.run_sidebar_categories_phase_2_migration.internal_error", + "translation": "Migration failed due to database error." + }, + { + "id": "migrations.worker.run_sidebar_categories_phase_2_migration.invalid_progress", + "translation": "Migration failed due to invalid progress data." + }, { "id": "model.access.is_valid.access_token.app_error", "translation": "Invalid access token." @@ -6194,6 +6210,18 @@ "id": "searchengine.bleve.disabled.error", "translation": "Error purging Bleve indexes: engine is disabled" }, + { + "id": "sidebar.category.channels", + "translation": "Channels" + }, + { + "id": "sidebar.category.dm", + "translation": "Direct Messages" + }, + { + "id": "sidebar.category.favorites", + "translation": "Favorites" + }, { "id": "store.insert_error", "translation": "insert error" @@ -6426,6 +6454,22 @@ "id": "store.sql_channel.search_group_channels.app_error", "translation": "Unable to get the group channels for the given user and term." }, + { + "id": "store.sql_channel.sidebar_categories.app_error", + "translation": "Failed to insert record to database." + }, + { + "id": "store.sql_channel.sidebar_categories.commit_transaction.app_error", + "translation": "Unable to commit transaction." + }, + { + "id": "store.sql_channel.sidebar_categories.delete_invalid.app_error", + "translation": "Unable to delete non-custom category." + }, + { + "id": "store.sql_channel.sidebar_categories.open_transaction.app_error", + "translation": "Failed to open the database transaction." + }, { "id": "store.sql_channel.update_last_viewed_at.app_error", "translation": "Unable to update the last viewed at time." diff --git a/migrations/migrations.go b/migrations/migrations.go index 7dfc9117bf..8d419503f5 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -32,6 +32,7 @@ func init() { func MakeMigrationsList() []string { return []string{ model.MIGRATION_KEY_ADVANCED_PERMISSIONS_PHASE_2, + model.MIGRATION_KEY_SIDEBAR_CATEGORIES_PHASE_2, } } diff --git a/migrations/sidebar_categories_phase_2.go b/migrations/sidebar_categories_phase_2.go new file mode 100644 index 0000000000..27ef0896ab --- /dev/null +++ b/migrations/sidebar_categories_phase_2.go @@ -0,0 +1,128 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package migrations + +import ( + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/mattermost/mattermost-server/v5/model" +) + +type ProgressStep string + +const ( + StepCategories ProgressStep = "populateSidebarCategories" + StepFavorites ProgressStep = "migrateFavoriteChannelToSidebarChannels" + StepEnd ProgressStep = "endMigration" +) + +type Progress struct { + CurrentStep ProgressStep `json:"current_state"` + LastTeamId string `json:"last_team_id"` + LastChannelId string `json:"last_channel_id"` + LastUserId string `json:"last_user"` + LastSortOrder int64 `json:"last_sort_order"` +} + +func (p *Progress) ToJson() string { + b, _ := json.Marshal(p) + return string(b) +} + +func ProgressFromJson(data io.Reader) *Progress { + var o *Progress + json.NewDecoder(data).Decode(&o) + return o +} + +func (p *Progress) IsValid() bool { + if len(p.LastChannelId) != 26 { + return false + } + + if len(p.LastTeamId) != 26 { + return false + } + + if len(p.LastUserId) != 26 { + return false + } + + switch p.CurrentStep { + case StepCategories, StepFavorites: + return true + default: + return false + } +} + +func newProgress(step ProgressStep) *Progress { + progress := new(Progress) + progress.CurrentStep = step + progress.LastChannelId = strings.Repeat("0", 26) + progress.LastTeamId = strings.Repeat("0", 26) + progress.LastUserId = strings.Repeat("0", 26) + progress.LastSortOrder = 0 + return progress +} + +func (worker *Worker) runSidebarCategoriesPhase2Migration(lastDone string) (bool, string, *model.AppError) { + var progress *Progress + if len(lastDone) == 0 { + progress = newProgress(StepCategories) + } else { + progress = ProgressFromJson(strings.NewReader(lastDone)) + if !progress.IsValid() { + return false, "", model.NewAppError("MigrationsWorker.runSidebarCategoriesPhase2Migration", "migrations.worker.run_sidebar_categories_phase_2_migration.invalid_progress", map[string]interface{}{"progress": progress.ToJson()}, "", http.StatusInternalServerError) + } + } + + var result map[string]interface{} + var nErr error + var nextStep ProgressStep + switch progress.CurrentStep { + case StepCategories: + result, nErr = worker.srv.Store.Channel().MigrateSidebarCategories(progress.LastTeamId, progress.LastUserId) + nextStep = StepFavorites + case StepFavorites: + result, nErr = worker.srv.Store.Channel().MigrateFavoritesToSidebarChannels(progress.LastUserId, progress.LastSortOrder) + nextStep = StepEnd + default: + return false, "", model.NewAppError("MigrationsWorker.runSidebarCategoriesPhase2Migration", "migrations.worker.run_sidebar_categories_phase_2_migration.invalid_progress", map[string]interface{}{"progress": progress.ToJson()}, "", http.StatusInternalServerError) + } + + if nErr != nil { + return false, progress.ToJson(), model.NewAppError("MigrationsWorker.runSidebarCategoriesPhase2Migration", "migrations.worker.run_sidebar_categories_phase_2_migration.internal_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + if result == nil { + // We haven't progressed. That means that we've reached the end of this stage of the migration, and should now advance to the next stage or stop + if nextStep != StepEnd { + progress = newProgress(nextStep) + return false, progress.ToJson(), nil + } + return true, progress.ToJson(), nil + } + + progress.LastChannelId = strings.Repeat("0", 26) + progress.LastTeamId = strings.Repeat("0", 26) + progress.LastUserId = strings.Repeat("0", 26) + progress.LastSortOrder = 0 + if val, ok := result["UserId"].(string); ok { + progress.LastUserId = val + } + if val, ok := result["TeamId"].(string); ok { + progress.LastTeamId = val + } + if val, ok := result["ChannelId"].(string); ok { + progress.LastChannelId = val + } + if val, ok := result["SortOrder"].(int64); ok { + progress.LastSortOrder = val + } + return false, progress.ToJson(), nil +} diff --git a/migrations/worker.go b/migrations/worker.go index f1067aaea7..9b198e4ce6 100644 --- a/migrations/worker.go +++ b/migrations/worker.go @@ -150,6 +150,8 @@ func (worker *Worker) runMigration(key string, lastDone string) (bool, string, * var err *model.AppError switch key { + case model.MIGRATION_KEY_SIDEBAR_CATEGORIES_PHASE_2: + done, progress, err = worker.runSidebarCategoriesPhase2Migration(lastDone) case model.MIGRATION_KEY_ADVANCED_PERMISSIONS_PHASE_2: done, progress, err = worker.runAdvancedPermissionsPhase2Migration(lastDone) default: diff --git a/model/channel_sidebar.go b/model/channel_sidebar.go new file mode 100644 index 0000000000..6a79593c9a --- /dev/null +++ b/model/channel_sidebar.go @@ -0,0 +1,111 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type SidebarCategoryType string +type SidebarCategorySorting string + +const ( + // Each sidebar category has a 'type'. System categories are Channels, Favorites and DMs + // All user-created categories will have type Custom + SidebarCategoryChannels SidebarCategoryType = "channels" + SidebarCategoryDirectMessages SidebarCategoryType = "direct_messages" + SidebarCategoryFavorites SidebarCategoryType = "favorites" + SidebarCategoryCustom SidebarCategoryType = "custom" + // Increment to use when adding/reordering things in the sidebar + MinimalSidebarSortDistance = 10 + // Default Sort Orders for categories + DefaultSidebarSortOrderFavorites = 0 + DefaultSidebarSortOrderChannels = DefaultSidebarSortOrderFavorites + MinimalSidebarSortDistance + DefaultSidebarSortOrderDMs = DefaultSidebarSortOrderChannels + MinimalSidebarSortDistance + // Sorting modes + // default for all categories except DMs (behaves like manual) + SidebarCategorySortDefault SidebarCategorySorting = "" + // sort manually + SidebarCategorySortManual SidebarCategorySorting = "manual" + // sort by recency (default for DMs) + SidebarCategorySortRecent SidebarCategorySorting = "recent" + // sort by display name alphabetically + SidebarCategorySortAlphabetical SidebarCategorySorting = "alpha" +) + +// SidebarCategory represents the corresponding DB table +// SortOrder is never returned to the user and only used for queries +type SidebarCategory struct { + Id string `json:"id"` + UserId string `json:"user_id"` + TeamId string `json:"team_id"` + SortOrder int64 `json:"-"` + Sorting SidebarCategorySorting `json:"sorting"` + Type SidebarCategoryType `json:"type"` + DisplayName string `json:"display_name"` +} + +// SidebarCategoryWithChannels combines data from SidebarCategory table with the Channel IDs that belong to that category +type SidebarCategoryWithChannels struct { + SidebarCategory + Channels []string `json:"channel_ids"` +} + +type SidebarCategoryOrder []string + +// OrderedSidebarCategories combines categories, their channel IDs and an array of Category IDs, sorted +type OrderedSidebarCategories struct { + Categories SidebarCategoriesWithChannels `json:"categories"` + Order SidebarCategoryOrder `json:"order"` +} + +type SidebarChannel struct { + ChannelId string `json:"channel_id"` + UserId string `json:"user_id"` + CategoryId string `json:"category_id"` + SortOrder int64 `json:"-"` +} + +type SidebarChannels []*SidebarChannel +type SidebarCategoriesWithChannels []*SidebarCategoryWithChannels + +func SidebarCategoryFromJson(data io.Reader) (*SidebarCategoryWithChannels, error) { + var o *SidebarCategoryWithChannels + err := json.NewDecoder(data).Decode(&o) + return o, err +} + +func SidebarCategoriesFromJson(data io.Reader) ([]*SidebarCategoryWithChannels, error) { + var o []*SidebarCategoryWithChannels + err := json.NewDecoder(data).Decode(&o) + return o, err +} + +func OrderedSidebarCategoriesFromJson(data io.Reader) (*OrderedSidebarCategories, error) { + var o *OrderedSidebarCategories + err := json.NewDecoder(data).Decode(&o) + return o, err +} + +func (o SidebarCategoryWithChannels) ToJson() []byte { + b, _ := json.Marshal(o) + return b +} + +func SidebarCategoriesWithChannelsToJson(o []*SidebarCategoryWithChannels) []byte { + if b, err := json.Marshal(o); err != nil { + return []byte("[]") + } else { + return b + } +} + +func (o OrderedSidebarCategories) ToJson() []byte { + if b, err := json.Marshal(o); err != nil { + return []byte("[]") + } else { + return b + } +} diff --git a/model/client4.go b/model/client4.go index a2b84a8c56..0b0c05e4c7 100644 --- a/model/client4.go +++ b/model/client4.go @@ -174,6 +174,10 @@ func (c *Client4) GetUserRoute(userId string) string { return fmt.Sprintf(c.GetUsersRoute()+"/%v", userId) } +func (c *Client4) GetUserCategoryRoute(userID, teamID string) string { + return c.GetUserRoute(userID) + c.GetTeamRoute(teamID) + "/channels/categories" +} + func (c *Client4) GetUserAccessTokensRoute() string { return fmt.Sprintf(c.GetUsersRoute() + "/tokens") } @@ -5212,3 +5216,83 @@ func (c *Client4) GetGroupStats(groupID string) (*GroupStats, *Response) { defer closeBody(r) return GroupStatsFromJson(r.Body), BuildResponse(r) } + +func (c *Client4) GetSidebarCategoriesForTeamForUser(userID, teamID, etag string) (*OrderedSidebarCategories, *Response) { + route := c.GetUserCategoryRoute(userID, teamID) + r, appErr := c.DoApiGet(route, etag) + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + cat, err := OrderedSidebarCategoriesFromJson(r.Body) + if err != nil { + return nil, BuildErrorResponse(r, NewAppError("Client4.GetSidebarCategoriesForTeamForUser", "model.utils.decode_json.app_error", nil, err.Error(), r.StatusCode)) + } + return cat, BuildResponse(r) +} + +func (c *Client4) CreateSidebarCategoryForTeamForUser(userID, teamID string, category *SidebarCategoryWithChannels) (*SidebarCategoryWithChannels, *Response) { + payload, _ := json.Marshal(category) + route := c.GetUserCategoryRoute(userID, teamID) + r, appErr := c.doApiPostBytes(route, payload) + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + cat, err := SidebarCategoryFromJson(r.Body) + if err != nil { + return nil, BuildErrorResponse(r, NewAppError("Client4.CreateSidebarCategoryForTeamForUser", "model.utils.decode_json.app_error", nil, err.Error(), r.StatusCode)) + } + return cat, BuildResponse(r) +} + +func (c *Client4) GetSidebarCategoryOrderForTeamForUser(userID, teamID, etag string) ([]string, *Response) { + route := c.GetUserCategoryRoute(userID, teamID) + "/order" + r, err := c.DoApiGet(route, etag) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + return ArrayFromJson(r.Body), BuildResponse(r) +} + +func (c *Client4) UpdateSidebarCategoryOrderForTeamForUser(userID, teamID string, order []string) ([]string, *Response) { + payload, _ := json.Marshal(order) + route := c.GetUserCategoryRoute(userID, teamID) + "/order" + r, err := c.doApiPutBytes(route, payload) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + return ArrayFromJson(r.Body), BuildResponse(r) +} + +func (c *Client4) GetSidebarCategoryForTeamForUser(userID, teamID, categoryID, etag string) (*SidebarCategoryWithChannels, *Response) { + route := c.GetUserCategoryRoute(userID, teamID) + "/" + categoryID + r, appErr := c.DoApiGet(route, etag) + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + cat, err := SidebarCategoryFromJson(r.Body) + if err != nil { + return nil, &Response{StatusCode: http.StatusBadRequest, Error: NewAppError(c.GetUserRoute(userID), "model.client.connecting.app_error", nil, err.Error(), http.StatusForbidden)} + } + + return cat, BuildResponse(r) +} + +func (c *Client4) UpdateSidebarCategoryForTeamForUser(userID, teamID, categoryID string, category *SidebarCategoryWithChannels) (*SidebarCategoryWithChannels, *Response) { + payload, _ := json.Marshal(category) + route := c.GetUserCategoryRoute(userID, teamID) + "/" + categoryID + r, appErr := c.doApiPutBytes(route, payload) + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + cat, err := SidebarCategoryFromJson(r.Body) + if err != nil { + return nil, &Response{StatusCode: http.StatusBadRequest, Error: NewAppError(c.GetUserRoute(userID), "model.client.connecting.app_error", nil, err.Error(), http.StatusForbidden)} + } + + return cat, BuildResponse(r) +} diff --git a/model/migration.go b/model/migration.go index 7dd08bef6e..7a7072e91f 100644 --- a/model/migration.go +++ b/model/migration.go @@ -17,4 +17,6 @@ const ( MIGRATION_KEY_ADD_MANAGE_GUESTS_PERMISSIONS = "add_manage_guests_permissions" MIGRATION_KEY_CHANNEL_MODERATIONS_PERMISSIONS = "channel_moderations_permissions" MIGRATION_KEY_ADD_USE_GROUP_MENTIONS_PERMISSION = "add_use_group_mentions_permission" + + MIGRATION_KEY_SIDEBAR_CATEGORIES_PHASE_2 = "migration_sidebar_categories_phase_2" ) diff --git a/model/preference.go b/model/preference.go index 346f88f832..e752bb54c0 100644 --- a/model/preference.go +++ b/model/preference.go @@ -14,6 +14,7 @@ import ( const ( PREFERENCE_CATEGORY_DIRECT_CHANNEL_SHOW = "direct_channel_show" + PREFERENCE_CATEGORY_GROUP_CHANNEL_SHOW = "group_channel_show" PREFERENCE_CATEGORY_TUTORIAL_STEPS = "tutorial_step" PREFERENCE_CATEGORY_ADVANCED_SETTINGS = "advanced_settings" PREFERENCE_CATEGORY_FLAGGED_POST = "flagged_post" diff --git a/model/websocket_message.go b/model/websocket_message.go index b3e4b18625..0fd05ef3dd 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -62,6 +62,10 @@ const ( WEBSOCKET_EVENT_RECEIVED_GROUP_NOT_ASSOCIATED_TO_TEAM = "received_group_not_associated_to_team" WEBSOCKET_EVENT_RECEIVED_GROUP_ASSOCIATED_TO_CHANNEL = "received_group_associated_to_channel" WEBSOCKET_EVENT_RECEIVED_GROUP_NOT_ASSOCIATED_TO_CHANNEL = "received_group_not_associated_to_channel" + WEBSOCKET_EVENT_SIDEBAR_CATEGORY_CREATED = "sidebar_category_created" + WEBSOCKET_EVENT_SIDEBAR_CATEGORY_UPDATED = "sidebar_category_updated" + WEBSOCKET_EVENT_SIDEBAR_CATEGORY_DELETED = "sidebar_category_deleted" + WEBSOCKET_EVENT_SIDEBAR_CATEGORY_ORDER_UPDATED = "sidebar_category_order_updated" ) type WebSocketMessage interface { diff --git a/store/opentracing_layer.go b/store/opentracing_layer.go index afb860a72e..903f7dc761 100644 --- a/store/opentracing_layer.go +++ b/store/opentracing_layer.go @@ -576,6 +576,24 @@ func (s *OpenTracingLayerChannelStore) ClearCaches() { } +func (s *OpenTracingLayerChannelStore) ClearSidebarOnTeamLeave(userId string, teamId string) *model.AppError { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.ClearSidebarOnTeamLeave") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0 := s.ChannelStore.ClearSidebarOnTeamLeave(userId, teamId) + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (s *OpenTracingLayerChannelStore) CountPostsAfter(channelId string, timestamp int64, userId string) (int, *model.AppError) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.CountPostsAfter") @@ -612,6 +630,42 @@ func (s *OpenTracingLayerChannelStore) CreateDirectChannel(userId *model.User, o return resultVar0, resultVar1 } +func (s *OpenTracingLayerChannelStore) CreateInitialSidebarCategories(user *model.User, teamId string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.CreateInitialSidebarCategories") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0 := s.ChannelStore.CreateInitialSidebarCategories(user, teamId) + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + +func (s *OpenTracingLayerChannelStore) CreateSidebarCategory(userId string, teamId string, newCategory *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.CreateSidebarCategory") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0, resultVar1 := s.ChannelStore.CreateSidebarCategory(userId, teamId, newCategory) + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (s *OpenTracingLayerChannelStore) Delete(channelId string, time int64) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.Delete") @@ -630,6 +684,24 @@ func (s *OpenTracingLayerChannelStore) Delete(channelId string, time int64) erro return resultVar0 } +func (s *OpenTracingLayerChannelStore) DeleteSidebarCategory(categoryId string) *model.AppError { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.DeleteSidebarCategory") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0 := s.ChannelStore.DeleteSidebarCategory(categoryId) + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (s *OpenTracingLayerChannelStore) Get(id string, allowFromCache bool) (*model.Channel, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.Get") @@ -1327,6 +1399,60 @@ func (s *OpenTracingLayerChannelStore) GetPublicChannelsForTeam(teamId string, o return resultVar0, resultVar1 } +func (s *OpenTracingLayerChannelStore) GetSidebarCategories(userId string, teamId string) (*model.OrderedSidebarCategories, *model.AppError) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.GetSidebarCategories") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0, resultVar1 := s.ChannelStore.GetSidebarCategories(userId, teamId) + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (s *OpenTracingLayerChannelStore) GetSidebarCategory(categoryId string) (*model.SidebarCategoryWithChannels, *model.AppError) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.GetSidebarCategory") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0, resultVar1 := s.ChannelStore.GetSidebarCategory(categoryId) + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (s *OpenTracingLayerChannelStore) GetSidebarCategoryOrder(userId string, teamId string) ([]string, *model.AppError) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.GetSidebarCategoryOrder") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0, resultVar1 := s.ChannelStore.GetSidebarCategoryOrder(userId, teamId) + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (s *OpenTracingLayerChannelStore) GetTeamChannels(teamId string) (*model.ChannelList, *model.AppError) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.GetTeamChannels") @@ -1503,6 +1629,24 @@ func (s *OpenTracingLayerChannelStore) MigrateChannelMembers(fromChannelId strin return resultVar0, resultVar1 } +func (s *OpenTracingLayerChannelStore) MigrateFavoritesToSidebarChannels(lastUserId string, runningOrder int64) (map[string]interface{}, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.MigrateFavoritesToSidebarChannels") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0, resultVar1 := s.ChannelStore.MigrateFavoritesToSidebarChannels(lastUserId, runningOrder) + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (s *OpenTracingLayerChannelStore) MigratePublicChannels() error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.MigratePublicChannels") @@ -1521,6 +1665,24 @@ func (s *OpenTracingLayerChannelStore) MigratePublicChannels() error { return resultVar0 } +func (s *OpenTracingLayerChannelStore) MigrateSidebarCategories(fromTeamId string, fromUserId string) (map[string]interface{}, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.MigrateSidebarCategories") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0, resultVar1 := s.ChannelStore.MigrateSidebarCategories(fromTeamId, fromUserId) + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (s *OpenTracingLayerChannelStore) PermanentDelete(channelId string) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.PermanentDelete") @@ -1989,6 +2151,78 @@ func (s *OpenTracingLayerChannelStore) UpdateMultipleMembers(members []*model.Ch return resultVar0, resultVar1 } +func (s *OpenTracingLayerChannelStore) UpdateSidebarCategories(userId string, teamId string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.UpdateSidebarCategories") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0, resultVar1 := s.ChannelStore.UpdateSidebarCategories(userId, teamId, categories) + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (s *OpenTracingLayerChannelStore) UpdateSidebarCategoryOrder(userId string, teamId string, categoryOrder []string) *model.AppError { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.UpdateSidebarCategoryOrder") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0 := s.ChannelStore.UpdateSidebarCategoryOrder(userId, teamId, categoryOrder) + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + +func (s *OpenTracingLayerChannelStore) UpdateSidebarChannelCategoryOnMove(channel *model.Channel, newTeamId string) *model.AppError { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.UpdateSidebarChannelCategoryOnMove") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0 := s.ChannelStore.UpdateSidebarChannelCategoryOnMove(channel, newTeamId) + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + +func (s *OpenTracingLayerChannelStore) UpdateSidebarChannelsByPreferences(preferences *model.Preferences) *model.AppError { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.UpdateSidebarChannelsByPreferences") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0 := s.ChannelStore.UpdateSidebarChannelsByPreferences(preferences) + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (s *OpenTracingLayerChannelStore) UserBelongsToChannels(userId string, channelIds []string) (bool, *model.AppError) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.UserBelongsToChannels") diff --git a/store/sqlstore/channel_store.go b/store/sqlstore/channel_store.go index d841ad1e44..4ecf5d45c9 100644 --- a/store/sqlstore/channel_store.go +++ b/store/sqlstore/channel_store.go @@ -18,6 +18,7 @@ import ( "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/services/cache2" "github.com/mattermost/mattermost-server/v5/store" + "github.com/mattermost/mattermost-server/v5/utils" sq "github.com/Masterminds/squirrel" "github.com/pkg/errors" @@ -390,6 +391,19 @@ func newSqlChannelStore(sqlStore SqlStore, metrics einterfaces.MetricsInterface) tablePublicChannels.SetUniqueTogether("Name", "TeamId") tablePublicChannels.ColMap("Header").SetMaxSize(1024) tablePublicChannels.ColMap("Purpose").SetMaxSize(250) + + tableSidebarCategories := db.AddTableWithName(model.SidebarCategory{}, "SidebarCategories").SetKeys(false, "Id") + tableSidebarCategories.ColMap("Id").SetMaxSize(26) + tableSidebarCategories.ColMap("UserId").SetMaxSize(26) + tableSidebarCategories.ColMap("TeamId").SetMaxSize(26) + tableSidebarCategories.ColMap("Sorting").SetMaxSize(64) + tableSidebarCategories.ColMap("Type").SetMaxSize(64) + tableSidebarCategories.ColMap("DisplayName").SetMaxSize(64) + + tableSidebarChannels := db.AddTableWithName(model.SidebarChannel{}, "SidebarChannels").SetKeys(false, "ChannelId", "UserId", "CategoryId") + tableSidebarChannels.ColMap("ChannelId").SetMaxSize(26) + tableSidebarChannels.ColMap("UserId").SetMaxSize(26) + tableSidebarChannels.ColMap("CategoryId").SetMaxSize(26) } return s @@ -423,6 +437,217 @@ func (s SqlChannelStore) createIndexesIfNotExists() { s.CreateIndexIfNotExists("idx_channels_scheme_id", "Channels", "SchemeId") } +// MigrateSidebarCategories creates 3 initial categories for all existing user/team pairs +// **IMPORTANT** This function should only be called from the migration task and shouldn't be used by itself +func (s SqlChannelStore) MigrateSidebarCategories(fromTeamId, fromUserId string) (map[string]interface{}, error) { + var userTeamMap []struct { + UserId string + TeamId string + Locale *string + } + + transaction, err := s.GetMaster().Begin() + if err != nil { + return nil, err + } + + defer finalizeTransaction(transaction) + + if _, err := transaction.Select(&userTeamMap, "SELECT TeamId, UserId, Users.Locale FROM TeamMembers LEFT JOIN Users ON Users.Id=UserId WHERE (TeamId, UserId) > (:FromTeamId, :FromUserId) ORDER BY TeamId, UserId LIMIT 100", map[string]interface{}{"FromTeamId": fromTeamId, "FromUserId": fromUserId}); err != nil { + return nil, err + } + + if len(userTeamMap) == 0 { + // No more team members in query result means that the migration has finished. + return nil, nil + } + + for _, u := range userTeamMap { + locale := "en" + if u.Locale != nil { + locale = *u.Locale + } + + if err := s.createInitialSidebarCategoriesT(transaction, &model.User{Id: u.UserId, Locale: locale}, u.TeamId); err != nil { + return nil, err + } + } + if err := transaction.Commit(); err != nil { + return nil, err + } + + data := make(map[string]interface{}) + data["TeamId"] = userTeamMap[len(userTeamMap)-1].TeamId + data["UserId"] = userTeamMap[len(userTeamMap)-1].UserId + + return data, nil +} + +func (s SqlChannelStore) CreateInitialSidebarCategories(user *model.User, teamId string) error { + transaction, err := s.GetMaster().Begin() + if err != nil { + return err + } + defer finalizeTransaction(transaction) + + if err := s.createInitialSidebarCategoriesT(transaction, user, teamId); err != nil { + return err + } + + if err := transaction.Commit(); err != nil { + return err + } + + return nil +} + +func (s SqlChannelStore) createInitialSidebarCategoriesT(transaction *gorp.Transaction, user *model.User, teamId string) error { + T := utils.GetUserTranslations(user.Locale) + + selectQuery, selectParams, _ := s.getQueryBuilder(). + Select("Type"). + From("SidebarCategories"). + Where(sq.Eq{ + "UserId": user.Id, + "TeamId": teamId, + "Type": []model.SidebarCategoryType{model.SidebarCategoryFavorites, model.SidebarCategoryChannels, model.SidebarCategoryDirectMessages}, + }).ToSql() + + var existingTypes []model.SidebarCategoryType + _, err := transaction.Select(&existingTypes, selectQuery, selectParams...) + if err != nil { + return err + } + + hasCategoryOfType := func(categoryType model.SidebarCategoryType) bool { + for _, existingType := range existingTypes { + if categoryType == existingType { + return true + } + } + + return false + } + + if !hasCategoryOfType(model.SidebarCategoryFavorites) { + if err := transaction.Insert(&model.SidebarCategory{ + DisplayName: T("sidebar.category.favorites"), + Id: model.NewId(), + UserId: user.Id, + TeamId: teamId, + Sorting: model.SidebarCategorySortDefault, + SortOrder: model.DefaultSidebarSortOrderFavorites, + Type: model.SidebarCategoryFavorites, + }); err != nil { + return err + } + } + + if !hasCategoryOfType(model.SidebarCategoryChannels) { + if err := transaction.Insert(&model.SidebarCategory{ + DisplayName: T("sidebar.category.channels"), + Id: model.NewId(), + UserId: user.Id, + TeamId: teamId, + Sorting: model.SidebarCategorySortDefault, + SortOrder: model.DefaultSidebarSortOrderChannels, + Type: model.SidebarCategoryChannels, + }); err != nil { + return err + } + } + + if !hasCategoryOfType(model.SidebarCategoryDirectMessages) { + if err := transaction.Insert(&model.SidebarCategory{ + DisplayName: T("sidebar.category.dm"), + Id: model.NewId(), + UserId: user.Id, + TeamId: teamId, + Sorting: model.SidebarCategorySortRecent, + SortOrder: model.DefaultSidebarSortOrderDMs, + Type: model.SidebarCategoryDirectMessages, + }); err != nil { + return err + } + } + + return nil +} + +type userMembership struct { + UserId string + ChannelId string + CategoryId string +} + +func (s SqlChannelStore) migrateMembershipToSidebar(transaction *gorp.Transaction, runningOrder *int64, sql string, args ...interface{}) ([]userMembership, error) { + var memberships []userMembership + if _, err := transaction.Select(&memberships, sql, args...); err != nil { + return nil, err + } + + for _, favorite := range memberships { + sql, args, _ := s.getQueryBuilder(). + Insert("SidebarChannels"). + Columns("ChannelId", "UserId", "CategoryId", "SortOrder"). + Values(favorite.ChannelId, favorite.UserId, favorite.CategoryId, *runningOrder).ToSql() + + if _, err := transaction.Exec(sql, args...); err != nil && !IsUniqueConstraintError(err, []string{"UserId", "PRIMARY"}) { + return nil, err + } + *runningOrder = *runningOrder + model.MinimalSidebarSortDistance + } + + if err := transaction.Commit(); err != nil { + return nil, err + } + return memberships, nil +} + +// MigrateFavoritesToSidebarChannels populates the SidebarChannels table by analyzing existing user preferences for favorites +// **IMPORTANT** This function should only be called from the migration task and shouldn't be used by itself +func (s SqlChannelStore) MigrateFavoritesToSidebarChannels(lastUserId string, runningOrder int64) (map[string]interface{}, error) { + transaction, err := s.GetMaster().Begin() + if err != nil { + return nil, err + } + + defer finalizeTransaction(transaction) + + sb := s. + getQueryBuilder(). + Select("Preferences.UserId", "Preferences.Name AS ChannelId", "SidebarCategories.Id AS CategoryId"). + From("Preferences"). + Where(sq.And{ + sq.Eq{"Preferences.Category": model.PREFERENCE_CATEGORY_FAVORITE_CHANNEL}, + sq.NotEq{"Preferences.Value": "false"}, + sq.NotEq{"SidebarCategories.Id": nil}, + sq.Gt{"Preferences.UserId": lastUserId}, + }). + LeftJoin("Channels ON (Channels.Id=Preferences.Name)"). + LeftJoin("SidebarCategories ON (SidebarCategories.UserId=Preferences.UserId AND SidebarCategories.Type='"+string(model.SidebarCategoryFavorites)+"' AND (SidebarCategories.TeamId=Channels.TeamId OR Channels.TeamId=''))"). + OrderBy("Preferences.UserId", "Channels.Name DESC"). + Limit(100) + + sql, args, err := sb.ToSql() + if err != nil { + return nil, err + } + + userFavorites, err := s.migrateMembershipToSidebar(transaction, &runningOrder, sql, args...) + if err != nil { + return nil, err + } + if len(userFavorites) == 0 { + return nil, nil + } + + data := make(map[string]interface{}) + data["UserId"] = userFavorites[len(userFavorites)-1].UserId + data["SortOrder"] = runningOrder + return data, nil +} + // MigratePublicChannels initializes the PublicChannels table with data created before this version // of the Mattermost server kept it up-to-date. func (s SqlChannelStore) MigratePublicChannels() error { @@ -1938,6 +2163,21 @@ func (s SqlChannelStore) RemoveMembers(channelId string, userIds []string) *mode if err != nil { return model.NewAppError("SqlChannelStore.RemoveMember", "store.sql_channel.remove_member.app_error", nil, "channel_id="+channelId+", "+err.Error(), http.StatusInternalServerError) } + + // cleanup sidebarchannels table if the user is no longer a member of that channel + sql, args, err = s.getQueryBuilder(). + Delete("SidebarChannels"). + Where(sq.And{ + sq.Eq{"ChannelId": channelId}, + sq.Eq{"UserId": userIds}, + }).ToSql() + if err != nil { + return model.NewAppError("SqlChannelStore.RemoveMember", "store.sql_channel.remove_member.app_error", nil, "channel_id="+channelId+", "+err.Error(), http.StatusInternalServerError) + } + _, err = s.GetMaster().Exec(sql, args...) + if err != nil { + return model.NewAppError("SqlChannelStore.RemoveMember", "store.sql_channel.remove_member.app_error", nil, "channel_id="+channelId+", "+err.Error(), http.StatusInternalServerError) + } return nil } @@ -3206,3 +3446,569 @@ func (s SqlChannelStore) GroupSyncedChannelCount() (int64, *model.AppError) { return count, nil } + +type sidebarCategoryForJoin struct { + model.SidebarCategory + ChannelId *string +} + +func (s SqlChannelStore) CreateSidebarCategory(userId, teamId string, newCategory *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) { + transaction, err := s.GetMaster().Begin() + if err != nil { + return nil, model.NewAppError("SqlChannelStore.CreateSidebarCategory", "store.sql_channel.sidebar_categories.open_transaction.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + defer finalizeTransaction(transaction) + + categoriesWithOrder, appErr := s.getSidebarCategoriesT(transaction, userId, teamId) + if appErr != nil { + return nil, appErr + } + if len(categoriesWithOrder.Categories) < 1 { + return nil, model.NewAppError("SqlChannelStore.CreateSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, "", http.StatusInternalServerError) + } + newOrder := categoriesWithOrder.Order + newCategoryId := model.NewId() + newCategorySortOrder := 0 + /* + When a new category is created, it should be placed as follows: + 1. If the Favorites category is first, the new category should be placed after it + 2. Otherwise, the new category should be placed first. + */ + if categoriesWithOrder.Categories[0].Type == model.SidebarCategoryFavorites { + newOrder = append([]string{newOrder[0], newCategoryId}, newOrder[1:]...) + newCategorySortOrder = model.MinimalSidebarSortDistance + } else { + newOrder = append([]string{newCategoryId}, newOrder...) + } + + category := &model.SidebarCategory{ + DisplayName: newCategory.DisplayName, + Id: newCategoryId, + UserId: userId, + TeamId: teamId, + Sorting: model.SidebarCategorySortDefault, + SortOrder: int64(model.MinimalSidebarSortDistance * len(newOrder)), // first we place it at the end of the list + Type: model.SidebarCategoryCustom, + } + if err = transaction.Insert(category); err != nil { + return nil, model.NewAppError("SqlPostStore.CreateSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + var channels []interface{} + runningOrder := 0 + for _, channelID := range newCategory.Channels { + channels = append(channels, &model.SidebarChannel{ + ChannelId: channelID, + CategoryId: newCategoryId, + SortOrder: int64(runningOrder), + UserId: userId, + }) + runningOrder += model.MinimalSidebarSortDistance + } + if err = transaction.Insert(channels...); err != nil { + return nil, model.NewAppError("SqlPostStore.CreateSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + // now we re-order the categories according to the new order + if appErr := s.updateSidebarCategoryOrderT(transaction, userId, teamId, newOrder); appErr != nil { + return nil, appErr + } + + if err = transaction.Commit(); err != nil { + return nil, model.NewAppError("SqlChannelStore.CreateSidebarCategory", "store.sql_channel.sidebar_categories.commit_transaction.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + // patch category to return proper sort order + category.SortOrder = int64(newCategorySortOrder) + result := &model.SidebarCategoryWithChannels{ + SidebarCategory: *category, + Channels: newCategory.Channels, + } + + return result, nil +} + +func (s SqlChannelStore) completePopulatingCategoryChannels(category *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) { + if category.Type == model.SidebarCategoryCustom || category.Type == model.SidebarCategoryFavorites { + return category, nil + } + + var channelTypeFilter sq.Sqlizer + if category.Type == model.SidebarCategoryDirectMessages { + // any DM/GM channels that aren't in any category should be returned as part of the Direct Messages category + channelTypeFilter = sq.Eq{"Channels.Type": []string{model.CHANNEL_DIRECT, model.CHANNEL_GROUP}} + } else if category.Type == model.SidebarCategoryChannels { + // any public/private channels that are on the current team and aren't in any category should be returned as part of the Channels category + channelTypeFilter = sq.And{ + sq.Eq{"Channels.Type": []string{model.CHANNEL_OPEN, model.CHANNEL_PRIVATE}}, + sq.Eq{"Channels.TeamId": category.TeamId}, + } + } + + // A subquery that is true if the channel does not have a SidebarChannel entry for the current user on the current team + doesNotHaveSidebarChannel := sq.Select("1"). + Prefix("NOT EXISTS ("). + From("SidebarChannels"). + Join("SidebarCategories on SidebarChannels.CategoryId=SidebarCategories.Id"). + Where(sq.And{ + sq.Expr("SidebarChannels.ChannelId = ChannelMembers.ChannelId"), + sq.Eq{"SidebarCategories.UserId": category.UserId}, + sq.Eq{"SidebarCategories.TeamId": category.TeamId}, + }). + Suffix(")") + + var channels []string + sql, args, _ := s.getQueryBuilder(). + Select("Id"). + From("ChannelMembers"). + LeftJoin("Channels ON Channels.Id=ChannelMembers.ChannelId"). + Where(sq.And{ + sq.Eq{"ChannelMembers.UserId": category.UserId}, + channelTypeFilter, + sq.Eq{"Channels.DeleteAt": 0}, + doesNotHaveSidebarChannel, + }). + OrderBy("DisplayName ASC").ToSql() + + if _, err := s.GetReplica().Select(&channels, sql, args...); err != nil { + return nil, model.NewAppError("SqlPostStore.completePopulatingCategoryChannels", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusNotFound) + } + category.Channels = append(channels, category.Channels...) + return category, nil +} + +func (s SqlChannelStore) GetSidebarCategory(categoryId string) (*model.SidebarCategoryWithChannels, *model.AppError) { + var categories []*sidebarCategoryForJoin + sql, args, _ := s.getQueryBuilder(). + Select("SidebarCategories.*", "SidebarChannels.ChannelId"). + From("SidebarCategories"). + LeftJoin("SidebarChannels ON SidebarChannels.CategoryId=SidebarCategories.Id"). + Where(sq.Eq{"SidebarCategories.Id": categoryId}). + OrderBy("SidebarChannels.SortOrder ASC").ToSql() + if _, err := s.GetReplica().Select(&categories, sql, args...); err != nil { + return nil, model.NewAppError("SqlPostStore.GetSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusNotFound) + } + result := &model.SidebarCategoryWithChannels{ + SidebarCategory: categories[0].SidebarCategory, + Channels: make([]string, 0), + } + for _, category := range categories { + if category.ChannelId != nil { + result.Channels = append(result.Channels, *category.ChannelId) + } + } + return s.completePopulatingCategoryChannels(result) +} + +func (s SqlChannelStore) getSidebarCategoriesT(transaction *gorp.Transaction, userId, teamId string) (*model.OrderedSidebarCategories, *model.AppError) { + oc := model.OrderedSidebarCategories{ + Categories: make(model.SidebarCategoriesWithChannels, 0), + Order: make([]string, 0), + } + + var categories []*sidebarCategoryForJoin + sql, args, _ := s.getQueryBuilder(). + Select("SidebarCategories.*", "SidebarChannels.ChannelId"). + From("SidebarCategories"). + LeftJoin("SidebarChannels ON SidebarChannels.CategoryId=Id"). + Where(sq.And{ + sq.Eq{"SidebarCategories.UserId": userId}, + sq.Eq{"SidebarCategories.TeamId": teamId}, + }). + OrderBy("SidebarCategories.SortOrder ASC, SidebarChannels.SortOrder ASC").ToSql() + + if _, err := s.GetReplica().Select(&categories, sql, args...); err != nil { + return nil, model.NewAppError("SqlPostStore.GetSidebarCategories", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusNotFound) + } + for _, category := range categories { + var prevCategory *model.SidebarCategoryWithChannels + for _, existing := range oc.Categories { + if existing.Id == category.Id { + prevCategory = existing + break + } + } + if prevCategory == nil { + prevCategory = &model.SidebarCategoryWithChannels{ + SidebarCategory: category.SidebarCategory, + Channels: make([]string, 0), + } + oc.Categories = append(oc.Categories, prevCategory) + oc.Order = append(oc.Order, category.Id) + } + if category.ChannelId != nil { + prevCategory.Channels = append(prevCategory.Channels, *category.ChannelId) + } + } + for _, category := range oc.Categories { + if _, err := s.completePopulatingCategoryChannels(category); err != nil { + return nil, err + } + } + + return &oc, nil +} + +func (s SqlChannelStore) GetSidebarCategories(userId, teamId string) (*model.OrderedSidebarCategories, *model.AppError) { + transaction, err := s.GetMaster().Begin() + if err != nil { + return nil, model.NewAppError("SqlChannelStore.GetSidebarCategories", "store.sql_channel.sidebar_categories.open_transaction.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + defer finalizeTransaction(transaction) + + oc, appErr := s.getSidebarCategoriesT(transaction, userId, teamId) + if appErr != nil { + return nil, appErr + } + + if err = transaction.Commit(); err != nil { + return nil, model.NewAppError("SqlChannelStore.GetSidebarCategories", "store.sql_channel.sidebar_categories.commit_transaction.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return oc, nil +} + +func (s SqlChannelStore) GetSidebarCategoryOrder(userId, teamId string) ([]string, *model.AppError) { + var ids []string + + sql, args, _ := s.getQueryBuilder(). + Select("Id"). + From("SidebarCategories"). + Where(sq.And{ + sq.Eq{"UserId": userId}, + sq.Eq{"TeamId": teamId}, + }). + OrderBy("SidebarCategories.SortOrder ASC").ToSql() + + if _, err := s.GetReplica().Select(&ids, sql, args...); err != nil { + return nil, model.NewAppError("SqlPostStore.GetSidebarCategoryOrder", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusNotFound) + } + return ids, nil +} + +func (s SqlChannelStore) updateSidebarCategoryOrderT(transaction *gorp.Transaction, userId, teamId string, categoryOrder []string) *model.AppError { + var newOrder []interface{} + runningOrder := 0 + for _, categoryId := range categoryOrder { + newOrder = append(newOrder, &model.SidebarCategory{ + Id: categoryId, + SortOrder: int64(runningOrder), + }) + runningOrder += model.MinimalSidebarSortDistance + } + + // There's a bug in gorp where UpdateColumns messes up the stored query for any other attempt to use .Update or + // .UpdateColumns on this table, so it's okay to use here as long as we don't use those methods for SidebarCategories + // anywhere else. + if _, err := transaction.UpdateColumns(func(col *gorp.ColumnMap) bool { + return col.ColumnName == "SortOrder" + }, newOrder...); err != nil { + return model.NewAppError("SqlPostStore.UpdateSidebarCategoryOrder", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return nil +} + +func (s SqlChannelStore) UpdateSidebarCategoryOrder(userId, teamId string, categoryOrder []string) *model.AppError { + transaction, err := s.GetMaster().Begin() + if err != nil { + return model.NewAppError("SqlChannelStore.UpdateSidebarCategoryOrder", "store.sql_channel.sidebar_categories.open_transaction.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + defer finalizeTransaction(transaction) + + // Ensure no invalid categories are included and that no categories are left out + existingOrder, appErr := s.GetSidebarCategoryOrder(userId, teamId) + if appErr != nil { + return appErr + } + if len(existingOrder) != len(categoryOrder) { + return model.NewAppError("SqlPostStore.UpdateSidebarCategoryOrder", "store.sql_channel.sidebar_categories.app_error", nil, "Cannot update category order, passed list of categories different size than in DB", http.StatusInternalServerError) + } + for _, originalCategoryId := range existingOrder { + found := false + for _, newCategoryId := range categoryOrder { + if newCategoryId == originalCategoryId { + found = true + break + } + } + if !found { + return model.NewAppError("SqlPostStore.UpdateSidebarCategoryOrder", "store.sql_channel.sidebar_categories.app_error", nil, "Cannot update category order, passed list of categories contains unrecognized category IDs", http.StatusBadRequest) + } + } + + if appErr := s.updateSidebarCategoryOrderT(transaction, userId, teamId, categoryOrder); appErr != nil { + return appErr + } + + if err = transaction.Commit(); err != nil { + return model.NewAppError("SqlChannelStore.UpdateSidebarCategoryOrder", "store.sql_channel.sidebar_categories.commit_transaction.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return nil +} + +func (s SqlChannelStore) UpdateSidebarCategories(userId, teamId string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) { + transaction, err := s.GetMaster().Begin() + if err != nil { + return nil, model.NewAppError("SqlChannelStore.UpdateSidebarCategory", "store.sql_channel.sidebar_categories.open_transaction.app_error", nil, err.Error(), http.StatusInternalServerError) + } + defer finalizeTransaction(transaction) + + updatedCategories := []*model.SidebarCategoryWithChannels{} + for _, category := range categories { + originalCategory, appErr := s.GetSidebarCategory(category.Id) + if appErr != nil { + return nil, model.NewAppError("SqlPostStore.UpdateSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, appErr.Error(), http.StatusInternalServerError) + } + + // Copy category to avoid modifying an argument + updatedCategory := &model.SidebarCategoryWithChannels{ + SidebarCategory: category.SidebarCategory, + } + + // Prevent any changes to read-only fields of SidebarCategories + updatedCategory.UserId = originalCategory.UserId + updatedCategory.TeamId = originalCategory.TeamId + updatedCategory.SortOrder = originalCategory.SortOrder + updatedCategory.Type = originalCategory.Type + + if updatedCategory.Type != model.SidebarCategoryCustom { + updatedCategory.DisplayName = originalCategory.DisplayName + } + + if category.Type != model.SidebarCategoryDirectMessages { + updatedCategory.Channels = make([]string, len(category.Channels)) + copy(updatedCategory.Channels, category.Channels) + } + + updateQuery, updateParams, _ := s.getQueryBuilder(). + Update("SidebarCategories"). + Set("DisplayName", updatedCategory.DisplayName). + Set("Sorting", updatedCategory.Sorting). + Where(sq.Eq{"Id": updatedCategory.Id}).ToSql() + + if _, err = transaction.Exec(updateQuery, updateParams...); err != nil { + return nil, model.NewAppError("SqlPostStore.UpdateSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + // if we are updating DM category, it's order can't channel order cannot be changed. + if category.Type != model.SidebarCategoryDirectMessages { + // Remove any SidebarChannels entries that were either: + // - previously in this category (and any ones that are still in the category will be recreated below) + // - in another category and are being added to this category + sql, args, _ := s.getQueryBuilder(). + Delete("SidebarChannels"). + Where( + sq.And{ + sq.Or{ + sq.Eq{"ChannelId": originalCategory.Channels}, + sq.Eq{"ChannelId": updatedCategory.Channels}, + }, + sq.Eq{"CategoryId": category.Id}, + }, + ).ToSql() + + if _, err = transaction.Exec(sql, args...); err != nil { + return nil, model.NewAppError("SqlPostStore.UpdateSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + var channels []interface{} + runningOrder := 0 + for _, channelID := range category.Channels { + channels = append(channels, &model.SidebarChannel{ + ChannelId: channelID, + CategoryId: category.Id, + SortOrder: int64(runningOrder), + UserId: userId, + }) + runningOrder += model.MinimalSidebarSortDistance + } + + if err = transaction.Insert(channels...); err != nil { + return nil, model.NewAppError("SqlPostStore.UpdateSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + + // Update the favorites preferences based on channels moving into or out of the Favorites category for compatibility + if category.Type == model.SidebarCategoryFavorites { + // Remove any old favorites + sql, args, _ := s.getQueryBuilder().Delete("Preferences").Where( + sq.Eq{ + "Name": originalCategory.Channels, + "Category": model.PREFERENCE_CATEGORY_FAVORITE_CHANNEL, + }, + ).ToSql() + + if _, err = transaction.Exec(sql, args...); err != nil { + return nil, model.NewAppError("SqlPostStore.UpdateSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + // And then add the new ones + var preferences []interface{} + + for _, channelID := range category.Channels { + preferences = append(preferences, &model.Preference{ + Name: channelID, + UserId: userId, + Category: model.PREFERENCE_CATEGORY_FAVORITE_CHANNEL, + Value: "true", + }) + } + + if err = transaction.Insert(preferences...); err != nil { + return nil, model.NewAppError("SqlPostStore.UpdateSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } else { + // Remove any old favorites that might have been in this category + sql, args, _ := s.getQueryBuilder().Delete("Preferences").Where( + sq.Eq{ + "Name": category.Channels, + "Category": model.PREFERENCE_CATEGORY_FAVORITE_CHANNEL, + }, + ).ToSql() + + if _, err = transaction.Exec(sql, args...); err != nil { + return nil, model.NewAppError("SqlPostStore.UpdateSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + + updatedCategories = append(updatedCategories, updatedCategory) + } + + if err = transaction.Commit(); err != nil { + return nil, model.NewAppError("SqlChannelStore.UpdateSidebarCategory", "store.sql_channel.sidebar_categories.commit_transaction.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + // Ensure Channels are populated for Channels/Direct Messages category if they change + for i, updatedCategory := range updatedCategories { + populated, err := s.completePopulatingCategoryChannels(updatedCategory) + if err != nil { + return nil, model.NewAppError("SqlPostStore.UpdateSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + updatedCategories[i] = populated + } + + return updatedCategories, nil +} + +// UpdateSidebarChannelByPreference is called when the Preference table is being updated to keep SidebarCategories in sync +// At the moment, it's only handling Favorites and NOT DMs/GMs (those will be handled client side) +func (s SqlChannelStore) UpdateSidebarChannelsByPreferences(preferences *model.Preferences) *model.AppError { + transaction, err := s.GetMaster().Begin() + if err != nil { + return model.NewAppError("SqlChannelStore.UpdateSidebarChannelsByPreferences", "store.sql_channel.sidebar_categories.open_transaction.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + defer finalizeTransaction(transaction) + for _, preference := range *preferences { + if preference.Category != model.PREFERENCE_CATEGORY_FAVORITE_CHANNEL { + continue + } + params := map[string]interface{}{ + "UserId": preference.UserId, + "ChannelId": preference.Name, + "CategoryType": model.SidebarCategoryFavorites, + } + // if new preference is false - remove the channel from the appropriate sidebar category + if preference.Value == "false" { + var deleteQuery string + if s.DriverName() == model.DATABASE_DRIVER_MYSQL { + deleteQuery = "DELETE SidebarChannels FROM SidebarChannels LEFT JOIN SidebarCategories ON SidebarCategories.Id = SidebarChannels.CategoryId WHERE SidebarCategories.Type=:CategoryType AND SidebarCategories.UserId=:UserId AND SidebarChannels.UserId=:UserId AND ChannelId=:ChannelId" + } else { + deleteQuery = "DELETE FROM SidebarChannels USING SidebarChannels AS chan LEFT OUTER JOIN SidebarCategories AS cat ON cat.Id = chan.CategoryId WHERE cat.Type=:CategoryType AND cat.UserId = :UserId AND chan.UserId = :UserId AND cat.TeamId = :TeamId AND chan.ChannelId=:ChannelId" + } + + if _, err := transaction.Exec(deleteQuery, params); err != nil { + return model.NewAppError("SqlChannelStore.UpdateSidebarChannelByPreference", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } else { + // otherwise - insert new channel into the apropriate category. ignore duplicate error + if _, err := transaction.Exec("INSERT INTO SidebarChannels (ChannelId, UserId, CategoryId, SortOrder) SELECT Id AS CategoryId, :UserId AS UserId, :ChannelId AS ChannelId, MAX(SidebarChannels.SortOrder)+10 FROM SidebarCategories INNER JOIN SidebarChannels ON SidebarChannels.CategoryId = SidebarCategories.Id WHERE SidebarCategories.Type=:CategoryType AND SidebarCategories.UserId=:UserId GROUP BY SidebarChannels.CategoryId, SidebarCategories.Id", params); err != nil && !IsUniqueConstraintError(err, []string{"UserId"}) { + return model.NewAppError("SqlChannelStore.UpdateSidebarChannelByPreference", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + } + + if err := transaction.Commit(); err != nil { + return model.NewAppError("SqlChannelStore.UpdateSidebarChannelByPreference", "store.sql_channel.sidebar_categories.commit_transaction.app_error", nil, err.Error(), http.StatusInternalServerError) + } + return nil +} + +func (s SqlChannelStore) UpdateSidebarChannelCategoryOnMove(channel *model.Channel, newTeamId string) *model.AppError { + // if channel is being moved, remove it from the categories, since it's possible that there's no matching category in the new team + if _, err := s.GetMaster().Exec("DELETE FROM SidebarChannels WHERE ChannelId=:ChannelId", map[string]interface{}{"ChannelId": channel.Id}); err != nil { + return model.NewAppError("SqlChannelStore.UpdateSidebarChannelCategoryOnMove", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + return nil +} + +func (s SqlChannelStore) ClearSidebarOnTeamLeave(userId, teamId string) *model.AppError { + // if user leaves the team, clean his team related entries in sidebar channels and categories + params := map[string]interface{}{ + "UserId": userId, + "TeamId": teamId, + } + + var deleteQuery string + if s.DriverName() == model.DATABASE_DRIVER_MYSQL { + deleteQuery = "DELETE SidebarChannels FROM SidebarChannels LEFT JOIN SidebarCategories ON SidebarCategories.Id = SidebarChannels.CategoryId WHERE SidebarCategories.TeamId=:TeamId AND SidebarCategories.UserId=:UserId" + } else { + deleteQuery = "DELETE FROM SidebarChannels USING SidebarChannels AS chan LEFT OUTER JOIN SidebarCategories AS cat ON cat.Id = chan.CategoryId WHERE cat.UserId = :UserId AND cat.TeamId = :TeamId" + } + if _, err := s.GetMaster().Exec(deleteQuery, params); err != nil { + return model.NewAppError("SqlChannelStore.ClearSidebarOnTeamLeave", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + if _, err := s.GetMaster().Exec("DELETE FROM SidebarCategories WHERE SidebarCategories.TeamId = :TeamId AND SidebarCategories.UserId = :UserId", params); err != nil { + return model.NewAppError("SqlChannelStore.ClearSidebarOnTeamLeave", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + return nil +} + +// DeleteSidebarCategory removes a custom category and moves any channels into it into the Channels and Direct Messages +// categories respectively. Assumes that the provided user ID and team ID match the given category ID. +func (s SqlChannelStore) DeleteSidebarCategory(categoryId string) *model.AppError { + transaction, err := s.GetMaster().Begin() + if err != nil { + return model.NewAppError("SqlChannelStore.DeleteSidebarCategory", "store.sql_channel.sidebar_categories.open_transaction.app_error", nil, err.Error(), http.StatusInternalServerError) + } + defer finalizeTransaction(transaction) + + // Ensure that we're deleting a custom category + var category *model.SidebarCategory + if err = transaction.SelectOne(&category, "SELECT * FROM SidebarCategories WHERE Id = :Id", map[string]interface{}{"Id": categoryId}); err != nil { + return model.NewAppError("SqlPostStore.DeleteSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + if category.Type != model.SidebarCategoryCustom { + return model.NewAppError("SqlPostStore.DeleteSidebarCategory", "store.sql_channel.sidebar_categories.delete_invalid.app_error", nil, "", http.StatusBadRequest) + } + + // Delete the channels in the category + sql, args, _ := s.getQueryBuilder(). + Delete("SidebarChannels"). + Where(sq.Eq{"CategoryId": categoryId}).ToSql() + + if _, err := transaction.Exec(sql, args...); err != nil { + return model.NewAppError("SqlPostStore.DeleteSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + // Delete the category itself + sql, args, _ = s.getQueryBuilder(). + Delete("SidebarCategories"). + Where(sq.Eq{"Id": categoryId}).ToSql() + + if _, err := transaction.Exec(sql, args...); err != nil { + return model.NewAppError("SqlChannelStore.DeleteSidebarCategory", "store.sql_channel.sidebar_categories.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + if err := transaction.Commit(); err != nil { + return model.NewAppError("SqlChannelStore.DeleteSidebarCategory", "store.sql_channel.sidebar_categories.commit_transaction.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return nil +} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 78ee6771f5..736602e424 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -65,6 +65,7 @@ type SqlStore interface { CreateUniqueIndexIfNotExists(indexName string, tableName string, columnName string) bool CreateIndexIfNotExists(indexName string, tableName string, columnName string) bool CreateCompositeIndexIfNotExists(indexName string, tableName string, columnNames []string) bool + CreateUniqueCompositeIndexIfNotExists(indexName string, tableName string, columnNames []string) bool CreateFullTextIndexIfNotExists(indexName string, tableName string, columnName string) bool RemoveIndexIfExists(indexName string, tableName string) bool GetAllConns() []*gorp.DbMap diff --git a/store/sqlstore/supplier.go b/store/sqlstore/supplier.go index 3a2a0b0e84..58f9ce98d9 100644 --- a/store/sqlstore/supplier.go +++ b/store/sqlstore/supplier.go @@ -855,6 +855,10 @@ func (ss *SqlSupplier) CreateCompositeIndexIfNotExists(indexName string, tableNa return ss.createIndexIfNotExists(indexName, tableName, columnNames, INDEX_TYPE_DEFAULT, false) } +func (ss *SqlSupplier) CreateUniqueCompositeIndexIfNotExists(indexName string, tableName string, columnNames []string) bool { + return ss.createIndexIfNotExists(indexName, tableName, columnNames, INDEX_TYPE_DEFAULT, true) +} + func (ss *SqlSupplier) CreateFullTextIndexIfNotExists(indexName string, tableName string, columnName string) bool { return ss.createIndexIfNotExists(indexName, tableName, []string{columnName}, INDEX_TYPE_FULL_TEXT, false) } diff --git a/store/store.go b/store/store.go index de5070a353..672b702560 100644 --- a/store/store.go +++ b/store/store.go @@ -138,6 +138,8 @@ type ChannelStore interface { CreateDirectChannel(userId *model.User, otherUserId *model.User) (*model.Channel, error) SaveDirectChannel(channel *model.Channel, member1 *model.ChannelMember, member2 *model.ChannelMember) (*model.Channel, error) Update(channel *model.Channel) (*model.Channel, error) + UpdateSidebarChannelCategoryOnMove(channel *model.Channel, newTeamId string) *model.AppError + ClearSidebarOnTeamLeave(userId, teamId string) *model.AppError Get(id string, allowFromCache bool) (*model.Channel, error) InvalidateChannel(id string) InvalidateChannelByName(teamId, name string) @@ -214,6 +216,17 @@ type ChannelStore interface { ResetAllChannelSchemes() *model.AppError ClearAllCustomRoleAssignments() *model.AppError MigratePublicChannels() error + MigrateSidebarCategories(fromTeamId, fromUserId string) (map[string]interface{}, error) + CreateInitialSidebarCategories(user *model.User, teamId string) error + MigrateFavoritesToSidebarChannels(lastUserId string, runningOrder int64) (map[string]interface{}, error) + GetSidebarCategories(userId, teamId string) (*model.OrderedSidebarCategories, *model.AppError) + GetSidebarCategory(categoryId string) (*model.SidebarCategoryWithChannels, *model.AppError) + GetSidebarCategoryOrder(userId, teamId string) ([]string, *model.AppError) + CreateSidebarCategory(userId, teamId string, newCategory *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) + UpdateSidebarCategoryOrder(userId, teamId string, categoryOrder []string) *model.AppError + UpdateSidebarCategories(userId, teamId string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) + UpdateSidebarChannelsByPreferences(preferences *model.Preferences) *model.AppError + DeleteSidebarCategory(categoryId string) *model.AppError GetAllChannelsForExportAfter(limit int, afterId string) ([]*model.ChannelForExport, *model.AppError) GetAllDirectChannelsForExportAfter(limit int, afterId string) ([]*model.DirectChannelForExport, *model.AppError) GetChannelMembersForExport(userId string, teamId string) ([]*model.ChannelMemberForExport, *model.AppError) diff --git a/store/storetest/channel_store.go b/store/storetest/channel_store.go index 665fdd4632..c5141c51eb 100644 --- a/store/storetest/channel_store.go +++ b/store/storetest/channel_store.go @@ -4,6 +4,7 @@ package storetest import ( + "database/sql" "errors" "sort" "strconv" @@ -99,6 +100,12 @@ func TestChannelStore(t *testing.T, ss store.Store, s SqlSupplier) { t.Run("ExportAllDirectChannelsDeletedChannel", func(t *testing.T) { testChannelStoreExportAllDirectChannelsDeletedChannel(t, ss, s) }) t.Run("GetChannelsBatchForIndexing", func(t *testing.T) { testChannelStoreGetChannelsBatchForIndexing(t, ss) }) t.Run("GroupSyncedChannelCount", func(t *testing.T) { testGroupSyncedChannelCount(t, ss) }) + t.Run("SidebarChannelsMigration", func(t *testing.T) { testSidebarChannelsMigration(t, ss) }) + t.Run("CreateInitialSidebarCategories", func(t *testing.T) { testCreateInitialSidebarCategories(t, ss) }) + t.Run("GetSidebarCategory", func(t *testing.T) { testGetSidebarCategory(t, ss, s) }) + t.Run("GetSidebarCategories", func(t *testing.T) { testGetSidebarCategories(t, ss) }) + t.Run("UpdateSidebarCategories", func(t *testing.T) { testUpdateSidebarCategories(t, ss, s) }) + t.Run("DeleteSidebarCategory", func(t *testing.T) { testDeleteSidebarCategory(t, ss, s) }) } func testChannelStoreSave(t *testing.T, ss store.Store) { @@ -6672,3 +6679,1172 @@ func testGroupSyncedChannelCount(t *testing.T, ss store.Store) { require.Nil(t, appErr) require.GreaterOrEqual(t, countAfter, count+1) } + +func testSidebarChannelsMigration(t *testing.T, ss store.Store) { + teamId := model.NewId() + + channel1, err := ss.Channel().Save(&model.Channel{ + DisplayName: model.NewId(), + Name: model.NewId(), + TeamId: teamId, + Type: model.CHANNEL_PRIVATE, + GroupConstrained: model.NewBool(true), + }, 10) + require.Nil(t, err) + defer func() { + ss.Channel().PermanentDeleteMembersByChannel(channel1.Id) + ss.Channel().PermanentDeleteByTeam(teamId) + ss.Channel().PermanentDelete(channel1.Id) + }() + + channel2, err := ss.Channel().Save(&model.Channel{ + DisplayName: model.NewId(), + Name: model.NewId(), + TeamId: teamId, + Type: model.CHANNEL_PRIVATE, + GroupConstrained: model.NewBool(true), + }, 10) + require.Nil(t, err) + defer func() { + ss.Channel().PermanentDeleteMembersByChannel(channel2.Id) + ss.Channel().PermanentDeleteByTeam(teamId) + ss.Channel().PermanentDelete(channel2.Id) + }() + + var users []*model.User + for i := 0; i < 3; i++ { + u := &model.User{Email: MakeEmail(), Nickname: model.NewId()} + _, err = ss.User().Save(u) + require.Nil(t, err) + _, err = ss.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: u.Id}, -1) + require.Nil(t, err) + users = append(users, u) + } + + _, err = ss.Channel().SaveMember(&model.ChannelMember{ + ChannelId: channel1.Id, + UserId: users[0].Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.Nil(t, err) + + _, err = ss.Channel().SaveMember(&model.ChannelMember{ + ChannelId: channel2.Id, + UserId: users[0].Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.Nil(t, err) + + err = ss.Preference().Save(&model.Preferences{ + { + Category: model.PREFERENCE_CATEGORY_FAVORITE_CHANNEL, + Name: channel1.Id, + UserId: users[0].Id, + Value: "true", + }, + }) + require.Nil(t, err) + + _, err = ss.Channel().CreateDirectChannel(users[0], users[1]) + require.Nil(t, err) + + t.Run("MigrateSidebarCategories", func(t *testing.T) { + _, nErr := ss.Channel().MigrateSidebarCategories(strings.Repeat("0", 26), strings.Repeat("0", 26)) + require.Nil(t, nErr) + res, err2 := ss.Channel().GetSidebarCategories(users[0].Id, teamId) + require.Nil(t, err2) + require.Len(t, res.Categories, 3) + require.Equal(t, model.SidebarCategoryFavorites, res.Categories[0].Type) + require.Equal(t, model.SidebarCategoryChannels, res.Categories[1].Type) + require.Equal(t, model.SidebarCategoryDirectMessages, res.Categories[2].Type) + }) + + t.Run("MigrateFavoritesToSidebarChannels", func(t *testing.T) { + _, nErr := ss.Channel().MigrateFavoritesToSidebarChannels(strings.Repeat("0", 26), 0) + require.Nil(t, nErr) + }) + + t.Run("GetSidebarCategories", func(t *testing.T) { + res, err := ss.Channel().GetSidebarCategories(users[0].Id, teamId) + require.Nil(t, err) + require.Equal(t, model.SidebarCategoryFavorites, res.Categories[0].Type) + require.Len(t, res.Categories[0].Channels, 1) + require.Equal(t, model.SidebarCategoryChannels, res.Categories[1].Type) + require.Len(t, res.Categories[1].Channels, 1) + require.Equal(t, model.SidebarCategoryDirectMessages, res.Categories[2].Type) + require.Len(t, res.Categories[2].Channels, 1) + }) + + t.Run("GetSidebarCategoriesWithoutNewChannel", func(t *testing.T) { + channel3, err := ss.Channel().Save(&model.Channel{ + DisplayName: model.NewId(), + Name: model.NewId(), + TeamId: teamId, + Type: model.CHANNEL_PRIVATE, + GroupConstrained: model.NewBool(true), + }, 10) + require.Nil(t, err) + channel4, err := ss.Channel().CreateDirectChannel(users[0], users[2]) + require.Nil(t, err) + + defer func() { + ss.Channel().PermanentDeleteMembersByChannel(channel3.Id) + ss.Channel().PermanentDelete(channel3.Id) + ss.Channel().PermanentDeleteMembersByChannel(channel4.Id) + ss.Channel().PermanentDelete(channel4.Id) + ss.Channel().PermanentDeleteByTeam(teamId) + }() + + _, err = ss.Channel().SaveMember(&model.ChannelMember{ + ChannelId: channel3.Id, + UserId: users[0].Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.Nil(t, err) + + res, err := ss.Channel().GetSidebarCategories(users[0].Id, teamId) + require.Nil(t, err) + require.Len(t, res.Categories[0].Channels, 1) + require.Len(t, res.Categories[1].Channels, 2) + require.Len(t, res.Categories[2].Channels, 2) + }) +} + +func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlSupplier) { + t.Run("should return a custom category with its Channels field set", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + channelId1 := model.NewId() + channelId2 := model.NewId() + channelId3 := model.NewId() + + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + // Create a category and assign some channels to it + created, err := ss.Channel().CreateSidebarCategory(user.Id, teamId, &model.SidebarCategoryWithChannels{ + SidebarCategory: model.SidebarCategory{ + UserId: user.Id, + TeamId: teamId, + DisplayName: model.NewId(), + }, + Channels: []string{channelId1, channelId2, channelId3}, + }) + require.Nil(t, err) + require.NotNil(t, created) + + // Ensure that they're returned in order + res, err := ss.Channel().GetSidebarCategory(created.Id) + assert.Nil(t, err) + assert.Equal(t, created.Id, res.Id) + assert.Equal(t, model.SidebarCategoryCustom, res.Type) + assert.Equal(t, created.DisplayName, res.DisplayName) + assert.Equal(t, []string{channelId1, channelId2, channelId3}, res.Channels) + }) + + t.Run("should return any orphaned channels with the Channels category", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + // Create the initial categories and find the channels category + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + categories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + + channelsCategory := categories.Categories[1] + require.Equal(t, model.SidebarCategoryChannels, channelsCategory.Type) + + // Join some channels + channel1, nErr := ss.Channel().Save(&model.Channel{ + Name: "channel1", + DisplayName: "DEF", + TeamId: teamId, + Type: model.CHANNEL_PRIVATE, + }, 10) + require.Nil(t, nErr) + _, err = ss.Channel().SaveMember(&model.ChannelMember{ + UserId: user.Id, + ChannelId: channel1.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.Nil(t, err) + + channel2, nErr := ss.Channel().Save(&model.Channel{ + Name: "channel2", + DisplayName: "ABC", + TeamId: teamId, + Type: model.CHANNEL_OPEN, + }, 10) + require.Nil(t, nErr) + _, err = ss.Channel().SaveMember(&model.ChannelMember{ + UserId: user.Id, + ChannelId: channel2.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.Nil(t, err) + + // Confirm that they're not in the Channels category in the DB + count, countErr := s.GetMaster().SelectInt(` + SELECT + COUNT(*) + FROM + SidebarChannels + WHERE + CategoryId = :CategoryId`, map[string]interface{}{"CategoryId": channelsCategory.Id}) + require.Nil(t, countErr) + assert.Equal(t, int64(0), count) + + // Ensure that the Channels are returned in alphabetical order + res, err := ss.Channel().GetSidebarCategory(channelsCategory.Id) + assert.Nil(t, err) + assert.Equal(t, channelsCategory.Id, res.Id) + assert.Equal(t, model.SidebarCategoryChannels, channelsCategory.Type) + assert.Equal(t, []string{channel2.Id, channel1.Id}, res.Channels) + }) + + t.Run("shouldn't return orphaned channels on another team with the Channels category", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + // Create the initial categories and find the channels category + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + categories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + require.Equal(t, model.SidebarCategoryChannels, categories.Categories[1].Type) + + channelsCategory := categories.Categories[1] + + // Join a channel on another team + channel1, nErr := ss.Channel().Save(&model.Channel{ + Name: "abc", + TeamId: model.NewId(), + Type: model.CHANNEL_OPEN, + }, 10) + require.Nil(t, nErr) + + _, err = ss.Channel().SaveMember(&model.ChannelMember{ + UserId: user.Id, + ChannelId: channel1.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.Nil(t, err) + + // Ensure that no channels are returned + res, err := ss.Channel().GetSidebarCategory(channelsCategory.Id) + assert.Nil(t, err) + assert.Equal(t, channelsCategory.Id, res.Id) + assert.Equal(t, model.SidebarCategoryChannels, channelsCategory.Type) + assert.Len(t, res.Channels, 0) + }) + + t.Run("shouldn't return non-orphaned channels with the Channels category", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + // Create the initial categories and find the channels category + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + categories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + + favoritesCategory := categories.Categories[0] + require.Equal(t, model.SidebarCategoryFavorites, favoritesCategory.Type) + channelsCategory := categories.Categories[1] + require.Equal(t, model.SidebarCategoryChannels, channelsCategory.Type) + + // Join some channels + channel1, nErr := ss.Channel().Save(&model.Channel{ + Name: "channel1", + DisplayName: "DEF", + TeamId: teamId, + Type: model.CHANNEL_PRIVATE, + }, 10) + require.Nil(t, nErr) + _, err = ss.Channel().SaveMember(&model.ChannelMember{ + UserId: user.Id, + ChannelId: channel1.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.Nil(t, err) + + channel2, nErr := ss.Channel().Save(&model.Channel{ + Name: "channel2", + DisplayName: "ABC", + TeamId: teamId, + Type: model.CHANNEL_OPEN, + }, 10) + require.Nil(t, nErr) + _, err = ss.Channel().SaveMember(&model.ChannelMember{ + UserId: user.Id, + ChannelId: channel2.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.Nil(t, err) + + // And assign one to another category + _, err = ss.Channel().UpdateSidebarCategories(user.Id, teamId, []*model.SidebarCategoryWithChannels{ + { + SidebarCategory: favoritesCategory.SidebarCategory, + Channels: []string{channel2.Id}, + }, + }) + require.Nil(t, err) + + // Ensure that the correct channel is returned in the Channels category + res, err := ss.Channel().GetSidebarCategory(channelsCategory.Id) + assert.Nil(t, err) + assert.Equal(t, channelsCategory.Id, res.Id) + assert.Equal(t, model.SidebarCategoryChannels, channelsCategory.Type) + assert.Equal(t, []string{channel1.Id}, res.Channels) + }) + + t.Run("should return any orphaned DM channels with the Direct Messages category", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + // Create the initial categories and find the DMs category + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + categories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + require.Equal(t, model.SidebarCategoryDirectMessages, categories.Categories[2].Type) + + dmsCategory := categories.Categories[2] + + // Create a DM + otherUserId := model.NewId() + dmChannel, nErr := ss.Channel().SaveDirectChannel( + &model.Channel{ + Name: model.GetDMNameFromIds(user.Id, otherUserId), + Type: model.CHANNEL_DIRECT, + }, + &model.ChannelMember{ + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }, + &model.ChannelMember{ + UserId: otherUserId, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }, + ) + require.Nil(t, nErr) + + // Ensure that the DM is returned + res, err := ss.Channel().GetSidebarCategory(dmsCategory.Id) + assert.Nil(t, err) + assert.Equal(t, dmsCategory.Id, res.Id) + assert.Equal(t, model.SidebarCategoryDirectMessages, res.Type) + assert.Equal(t, []string{dmChannel.Id}, res.Channels) + }) + + t.Run("should return any orphaned GM channels with the Direct Messages category", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + // Create the initial categories and find the DMs category + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + categories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + require.Equal(t, model.SidebarCategoryDirectMessages, categories.Categories[2].Type) + + dmsCategory := categories.Categories[2] + + // Create a GM + gmChannel, nErr := ss.Channel().Save(&model.Channel{ + Name: "abc", + TeamId: "", + Type: model.CHANNEL_GROUP, + }, 10) + require.Nil(t, nErr) + _, err = ss.Channel().SaveMember(&model.ChannelMember{ + UserId: user.Id, + ChannelId: gmChannel.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.Nil(t, err) + + // Ensure that the DM is returned + res, err := ss.Channel().GetSidebarCategory(dmsCategory.Id) + assert.Nil(t, err) + assert.Equal(t, dmsCategory.Id, res.Id) + assert.Equal(t, model.SidebarCategoryDirectMessages, res.Type) + assert.Equal(t, []string{gmChannel.Id}, res.Channels) + }) + + t.Run("should return orphaned DM channels in the DMs categorywhich are in a custom category on another team", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + // Create the initial categories and find the DMs category + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + categories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + require.Equal(t, model.SidebarCategoryDirectMessages, categories.Categories[2].Type) + + dmsCategory := categories.Categories[2] + + // Create a DM + otherUserId := model.NewId() + dmChannel, nErr := ss.Channel().SaveDirectChannel( + &model.Channel{ + Name: model.GetDMNameFromIds(user.Id, otherUserId), + Type: model.CHANNEL_DIRECT, + }, + &model.ChannelMember{ + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }, + &model.ChannelMember{ + UserId: otherUserId, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }, + ) + require.Nil(t, nErr) + + // Create another team and assign the DM to a custom category on that team + otherTeamId := model.NewId() + + nErr = ss.Channel().CreateInitialSidebarCategories(user, otherTeamId) + require.Nil(t, nErr) + + _, err = ss.Channel().CreateSidebarCategory(user.Id, otherTeamId, &model.SidebarCategoryWithChannels{ + SidebarCategory: model.SidebarCategory{ + UserId: user.Id, + TeamId: teamId, + }, + Channels: []string{dmChannel.Id}, + }) + require.Nil(t, err) + + // Ensure that the DM is returned with the DMs category on the original team + res, err := ss.Channel().GetSidebarCategory(dmsCategory.Id) + assert.Nil(t, err) + assert.Equal(t, dmsCategory.Id, res.Id) + assert.Equal(t, model.SidebarCategoryDirectMessages, res.Type) + assert.Equal(t, []string{dmChannel.Id}, res.Channels) + }) +} + +func testGetSidebarCategories(t *testing.T, ss store.Store) { + t.Run("should return channels in the same order between different ways of getting categories", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + channelIds := []string{ + model.NewId(), + model.NewId(), + model.NewId(), + } + + newCategory, err := ss.Channel().CreateSidebarCategory(user.Id, teamId, &model.SidebarCategoryWithChannels{ + Channels: channelIds, + }) + require.Nil(t, err) + require.NotNil(t, newCategory) + + gotCategory, err := ss.Channel().GetSidebarCategory(newCategory.Id) + require.Nil(t, err) + + res, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + require.Len(t, res.Categories, 4) + + require.Equal(t, model.SidebarCategoryCustom, res.Categories[1].Type) + + // This looks unnecessary, but I was getting different results from some of these before + assert.Equal(t, newCategory.Channels, res.Categories[1].Channels) + assert.Equal(t, gotCategory.Channels, res.Categories[1].Channels) + assert.Equal(t, channelIds, res.Categories[1].Channels) + }) +} + +func testUpdateSidebarCategories(t *testing.T, ss store.Store, s SqlSupplier) { + t.Run("ensure the query to update SidebarCategories hasn't been polluted by UpdateSidebarCategoryOrder", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + // Create the initial categories + err := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, err) + + initialCategories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + + favoritesCategory := initialCategories.Categories[0] + channelsCategory := initialCategories.Categories[1] + dmsCategory := initialCategories.Categories[2] + + // And then update one of them + updated, err := ss.Channel().UpdateSidebarCategories(user.Id, teamId, []*model.SidebarCategoryWithChannels{ + channelsCategory, + }) + require.Nil(t, err) + assert.Equal(t, channelsCategory, updated[0]) + assert.Equal(t, "Channels", updated[0].DisplayName) + + // And then reorder the categories + err = ss.Channel().UpdateSidebarCategoryOrder(user.Id, teamId, []string{dmsCategory.Id, favoritesCategory.Id, channelsCategory.Id}) + require.Nil(t, err) + + // Which somehow blanks out stuff because ??? + got, err := ss.Channel().GetSidebarCategory(favoritesCategory.Id) + require.Nil(t, err) + assert.Equal(t, "Favorites", got.DisplayName) + }) + + t.Run("categories should be returned in their original order", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + // Create the initial categories + err := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, err) + + initialCategories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + + favoritesCategory := initialCategories.Categories[0] + channelsCategory := initialCategories.Categories[1] + dmsCategory := initialCategories.Categories[2] + + // And then update them + updatedCategories, err := ss.Channel().UpdateSidebarCategories(user.Id, teamId, []*model.SidebarCategoryWithChannels{ + favoritesCategory, + channelsCategory, + dmsCategory, + }) + assert.Nil(t, err) + assert.Equal(t, favoritesCategory.Id, updatedCategories[0].Id) + assert.Equal(t, channelsCategory.Id, updatedCategories[1].Id) + assert.Equal(t, dmsCategory.Id, updatedCategories[2].Id) + }) + + t.Run("should silently fail to update read only fields", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + initialCategories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + + favoritesCategory := initialCategories.Categories[0] + channelsCategory := initialCategories.Categories[1] + dmsCategory := initialCategories.Categories[2] + + customCategory, err := ss.Channel().CreateSidebarCategory(user.Id, teamId, &model.SidebarCategoryWithChannels{}) + require.Nil(t, err) + + categoriesToUpdate := []*model.SidebarCategoryWithChannels{ + // Try to change the type of Favorites + { + SidebarCategory: model.SidebarCategory{ + Id: favoritesCategory.Id, + DisplayName: "something else", + }, + Channels: favoritesCategory.Channels, + }, + // Try to change the type of Channels + { + SidebarCategory: model.SidebarCategory{ + Id: channelsCategory.Id, + Type: model.SidebarCategoryDirectMessages, + }, + Channels: channelsCategory.Channels, + }, + // Try to change the Channels of DMs + { + SidebarCategory: dmsCategory.SidebarCategory, + Channels: []string{"fakechannel"}, + }, + // Try to change the UserId/TeamId of a custom category + { + SidebarCategory: model.SidebarCategory{ + Id: customCategory.Id, + UserId: model.NewId(), + TeamId: model.NewId(), + Sorting: customCategory.Sorting, + DisplayName: customCategory.DisplayName, + }, + Channels: customCategory.Channels, + }, + } + + updatedCategories, err := ss.Channel().UpdateSidebarCategories(user.Id, teamId, categoriesToUpdate) + assert.Nil(t, err) + + assert.NotEqual(t, "Favorites", categoriesToUpdate[0].DisplayName) + assert.Equal(t, "Favorites", updatedCategories[0].DisplayName) + assert.NotEqual(t, model.SidebarCategoryChannels, categoriesToUpdate[1].Type) + assert.Equal(t, model.SidebarCategoryChannels, updatedCategories[1].Type) + assert.NotEqual(t, []string{}, categoriesToUpdate[2].Channels) + assert.Equal(t, []string{}, updatedCategories[2].Channels) + assert.NotEqual(t, user.Id, categoriesToUpdate[3].UserId) + assert.Equal(t, user.Id, updatedCategories[3].UserId) + }) + + t.Run("should add and remove favorites preferences based on the Favorites category", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + // Create the initial categories and find the favorites category + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + categories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + + favoritesCategory := categories.Categories[0] + require.Equal(t, model.SidebarCategoryFavorites, favoritesCategory.Type) + + // Join a channel + channel, nErr := ss.Channel().Save(&model.Channel{ + Name: "channel", + Type: model.CHANNEL_OPEN, + TeamId: teamId, + }, 10) + require.Nil(t, nErr) + _, err = ss.Channel().SaveMember(&model.ChannelMember{ + UserId: user.Id, + ChannelId: channel.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.Nil(t, err) + + // Assign it to favorites + _, err = ss.Channel().UpdateSidebarCategories(user.Id, teamId, []*model.SidebarCategoryWithChannels{ + { + SidebarCategory: favoritesCategory.SidebarCategory, + Channels: []string{channel.Id}, + }, + }) + assert.Nil(t, err) + + res, err := ss.Preference().Get(user.Id, model.PREFERENCE_CATEGORY_FAVORITE_CHANNEL, channel.Id) + assert.Nil(t, err) + assert.NotNil(t, res) + assert.Equal(t, "true", res.Value) + + // And then remove it + channelsCategory := categories.Categories[1] + require.Equal(t, model.SidebarCategoryChannels, channelsCategory.Type) + + _, err = ss.Channel().UpdateSidebarCategories(user.Id, teamId, []*model.SidebarCategoryWithChannels{ + { + SidebarCategory: channelsCategory.SidebarCategory, + Channels: []string{channel.Id}, + }, + }) + assert.Nil(t, err) + + res, err = ss.Preference().Get(user.Id, model.PREFERENCE_CATEGORY_FAVORITE_CHANNEL, channel.Id) + assert.NotNil(t, err) + assert.Equal(t, sql.ErrNoRows.Error(), err.DetailedError) + assert.Nil(t, res) + }) + + t.Run("should add and remove favorites preferences for DMs", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + // Create the initial categories and find the favorites category + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + categories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + + favoritesCategory := categories.Categories[0] + require.Equal(t, model.SidebarCategoryFavorites, favoritesCategory.Type) + + // Create a direct channel + otherUserId := model.NewId() + + dmChannel, nErr := ss.Channel().SaveDirectChannel( + &model.Channel{ + Name: model.GetDMNameFromIds(user.Id, otherUserId), + Type: model.CHANNEL_DIRECT, + }, + &model.ChannelMember{ + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }, + &model.ChannelMember{ + UserId: otherUserId, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }, + ) + assert.Nil(t, nErr) + + // Assign it to favorites + _, err = ss.Channel().UpdateSidebarCategories(user.Id, teamId, []*model.SidebarCategoryWithChannels{ + { + SidebarCategory: favoritesCategory.SidebarCategory, + Channels: []string{dmChannel.Id}, + }, + }) + assert.Nil(t, err) + + res, err := ss.Preference().Get(user.Id, model.PREFERENCE_CATEGORY_FAVORITE_CHANNEL, dmChannel.Id) + assert.Nil(t, err) + assert.NotNil(t, res) + assert.Equal(t, "true", res.Value) + + // And then remove it + dmsCategory := categories.Categories[2] + require.Equal(t, model.SidebarCategoryDirectMessages, dmsCategory.Type) + + _, err = ss.Channel().UpdateSidebarCategories(user.Id, teamId, []*model.SidebarCategoryWithChannels{ + { + SidebarCategory: dmsCategory.SidebarCategory, + Channels: []string{dmChannel.Id}, + }, + }) + assert.Nil(t, err) + + res, err = ss.Preference().Get(user.Id, model.PREFERENCE_CATEGORY_FAVORITE_CHANNEL, dmChannel.Id) + assert.NotNil(t, err) + assert.Equal(t, sql.ErrNoRows.Error(), err.DetailedError) + assert.Nil(t, res) + }) + + t.Run("channels removed from Channels or DMs categories should be re-added", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + // Create some channels + channel, nErr := ss.Channel().Save(&model.Channel{ + Name: "channel", + Type: model.CHANNEL_OPEN, + TeamId: teamId, + }, 10) + require.Nil(t, nErr) + _, err := ss.Channel().SaveMember(&model.ChannelMember{ + UserId: user.Id, + ChannelId: channel.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.Nil(t, err) + + otherUserId := model.NewId() + dmChannel, nErr := ss.Channel().SaveDirectChannel( + &model.Channel{ + Name: model.GetDMNameFromIds(user.Id, otherUserId), + Type: model.CHANNEL_DIRECT, + }, + &model.ChannelMember{ + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }, + &model.ChannelMember{ + UserId: otherUserId, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }, + ) + require.Nil(t, nErr) + + nErr = ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + // And some categories + initialCategories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + + channelsCategory := initialCategories.Categories[1] + dmsCategory := initialCategories.Categories[2] + + require.Equal(t, []string{channel.Id}, channelsCategory.Channels) + require.Equal(t, []string{dmChannel.Id}, dmsCategory.Channels) + + // Try to save the categories with no channels in them + categoriesToUpdate := []*model.SidebarCategoryWithChannels{ + { + SidebarCategory: channelsCategory.SidebarCategory, + Channels: []string{}, + }, + { + SidebarCategory: dmsCategory.SidebarCategory, + Channels: []string{}, + }, + } + + updatedCategories, err := ss.Channel().UpdateSidebarCategories(user.Id, teamId, categoriesToUpdate) + assert.Nil(t, err) + + // The channels should still exist in the category because they would otherwise be orphaned + assert.Equal(t, []string{channel.Id}, updatedCategories[0].Channels) + assert.Equal(t, []string{dmChannel.Id}, updatedCategories[1].Channels) + }) + + t.Run("should be able to move DMs into and out of custom categories", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + otherUserId := model.NewId() + dmChannel, nErr := ss.Channel().SaveDirectChannel( + &model.Channel{ + Name: model.GetDMNameFromIds(user.Id, otherUserId), + Type: model.CHANNEL_DIRECT, + }, + &model.ChannelMember{ + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }, + &model.ChannelMember{ + UserId: otherUserId, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }, + ) + require.Nil(t, nErr) + + nErr = ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + // The DM should start in the DMs category + initialCategories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + + dmsCategory := initialCategories.Categories[2] + require.Equal(t, []string{dmChannel.Id}, dmsCategory.Channels) + + // Now move the DM into a custom category + customCategory, err := ss.Channel().CreateSidebarCategory(user.Id, teamId, &model.SidebarCategoryWithChannels{}) + require.Nil(t, err) + + categoriesToUpdate := []*model.SidebarCategoryWithChannels{ + { + SidebarCategory: dmsCategory.SidebarCategory, + Channels: []string{}, + }, + { + SidebarCategory: customCategory.SidebarCategory, + Channels: []string{dmChannel.Id}, + }, + } + + updatedCategories, err := ss.Channel().UpdateSidebarCategories(user.Id, teamId, categoriesToUpdate) + assert.Nil(t, err) + assert.Equal(t, dmsCategory.Id, updatedCategories[0].Id) + assert.Equal(t, []string{}, updatedCategories[0].Channels) + assert.Equal(t, customCategory.Id, updatedCategories[1].Id) + assert.Equal(t, []string{dmChannel.Id}, updatedCategories[1].Channels) + + updatedDmsCategory, err := ss.Channel().GetSidebarCategory(dmsCategory.Id) + require.Nil(t, err) + assert.Equal(t, []string{}, updatedDmsCategory.Channels) + + updatedCustomCategory, err := ss.Channel().GetSidebarCategory(customCategory.Id) + require.Nil(t, err) + assert.Equal(t, []string{dmChannel.Id}, updatedCustomCategory.Channels) + + // And move it back out of the custom category + categoriesToUpdate = []*model.SidebarCategoryWithChannels{ + { + SidebarCategory: dmsCategory.SidebarCategory, + Channels: []string{dmChannel.Id}, + }, + { + SidebarCategory: customCategory.SidebarCategory, + Channels: []string{}, + }, + } + + updatedCategories, err = ss.Channel().UpdateSidebarCategories(user.Id, teamId, categoriesToUpdate) + assert.Nil(t, err) + assert.Equal(t, dmsCategory.Id, updatedCategories[0].Id) + assert.Equal(t, []string{dmChannel.Id}, updatedCategories[0].Channels) + assert.Equal(t, customCategory.Id, updatedCategories[1].Id) + assert.Equal(t, []string{}, updatedCategories[1].Channels) + + updatedDmsCategory, err = ss.Channel().GetSidebarCategory(dmsCategory.Id) + require.Nil(t, err) + assert.Equal(t, []string{dmChannel.Id}, updatedDmsCategory.Channels) + + updatedCustomCategory, err = ss.Channel().GetSidebarCategory(customCategory.Id) + require.Nil(t, err) + assert.Equal(t, []string{}, updatedCustomCategory.Channels) + }) + + t.Run("should successfully move channels between categories", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + // Join a channel + channel, nErr := ss.Channel().Save(&model.Channel{ + Name: "channel", + Type: model.CHANNEL_OPEN, + TeamId: teamId, + }, 10) + require.Nil(t, nErr) + _, err := ss.Channel().SaveMember(&model.ChannelMember{ + UserId: user.Id, + ChannelId: channel.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + }) + require.Nil(t, err) + + // And then create the initial categories so that it includes the channel + nErr = ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + initialCategories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + + channelsCategory := initialCategories.Categories[1] + require.Equal(t, []string{channel.Id}, channelsCategory.Channels) + + customCategory, err := ss.Channel().CreateSidebarCategory(user.Id, teamId, &model.SidebarCategoryWithChannels{}) + require.Nil(t, err) + + // Move the channel one way + updatedCategories, err := ss.Channel().UpdateSidebarCategories(user.Id, teamId, []*model.SidebarCategoryWithChannels{ + { + SidebarCategory: channelsCategory.SidebarCategory, + Channels: []string{}, + }, + { + SidebarCategory: customCategory.SidebarCategory, + Channels: []string{channel.Id}, + }, + }) + assert.Nil(t, err) + + assert.Equal(t, []string{}, updatedCategories[0].Channels) + assert.Equal(t, []string{channel.Id}, updatedCategories[1].Channels) + + // And then the other + updatedCategories, err = ss.Channel().UpdateSidebarCategories(user.Id, teamId, []*model.SidebarCategoryWithChannels{ + { + SidebarCategory: channelsCategory.SidebarCategory, + Channels: []string{channel.Id}, + }, + { + SidebarCategory: customCategory.SidebarCategory, + Channels: []string{}, + }, + }) + assert.Nil(t, err) + assert.Equal(t, []string{channel.Id}, updatedCategories[0].Channels) + assert.Equal(t, []string{}, updatedCategories[1].Channels) + }) +} + +func testCreateInitialSidebarCategories(t *testing.T, ss store.Store) { + t.Run("should create initial favorites/channels/DMs categories", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + assert.Nil(t, nErr) + + res, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + assert.Nil(t, err) + assert.Len(t, res.Categories, 3) + assert.Equal(t, model.SidebarCategoryFavorites, res.Categories[0].Type) + assert.Equal(t, model.SidebarCategoryChannels, res.Categories[1].Type) + assert.Equal(t, model.SidebarCategoryDirectMessages, res.Categories[2].Type) + }) + + t.Run("should create initial favorites/channels/DMs categories for multiple users", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + user2 := &model.User{Id: model.NewId()} + + nErr = ss.Channel().CreateInitialSidebarCategories(user2, teamId) + assert.Nil(t, nErr) + + res, err := ss.Channel().GetSidebarCategories(user2.Id, teamId) + assert.Nil(t, err) + assert.Len(t, res.Categories, 3) + assert.Equal(t, model.SidebarCategoryFavorites, res.Categories[0].Type) + assert.Equal(t, model.SidebarCategoryChannels, res.Categories[1].Type) + assert.Equal(t, model.SidebarCategoryDirectMessages, res.Categories[2].Type) + }) + + t.Run("should create initial favorites/channels/DMs categories on different teams", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + teamId2 := model.NewId() + + nErr = ss.Channel().CreateInitialSidebarCategories(user, teamId2) + assert.Nil(t, nErr) + + res, err := ss.Channel().GetSidebarCategories(user.Id, teamId2) + assert.Nil(t, err) + assert.Len(t, res.Categories, 3) + assert.Equal(t, model.SidebarCategoryFavorites, res.Categories[0].Type) + assert.Equal(t, model.SidebarCategoryChannels, res.Categories[1].Type) + assert.Equal(t, model.SidebarCategoryDirectMessages, res.Categories[2].Type) + }) + + t.Run("shouldn't create additional categories when ones already exist", func(t *testing.T) { + user := &model.User{Id: model.NewId()} + teamId := model.NewId() + + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + initialCategories, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + + // Calling CreateInitialSidebarCategories a second time shouldn't create any new categories + nErr = ss.Channel().CreateInitialSidebarCategories(user, teamId) + assert.Nil(t, nErr) + + res, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + assert.Nil(t, err) + assert.Equal(t, initialCategories.Categories, res.Categories) + }) +} + +func testDeleteSidebarCategory(t *testing.T, ss store.Store, s SqlSupplier) { + setupInitialSidebarCategories := func(t *testing.T, ss store.Store) (string, string) { + user, err := ss.User().Save(&model.User{ + Email: MakeEmail(), + }) + require.Nil(t, err) + + teamId := model.NewId() + + nErr := ss.Channel().CreateInitialSidebarCategories(user, teamId) + require.Nil(t, nErr) + + res, err := ss.Channel().GetSidebarCategories(user.Id, teamId) + require.Nil(t, err) + require.Len(t, res.Categories, 3) + + return user.Id, teamId + } + + t.Run("should correctly remove an empty category", func(t *testing.T) { + userId, teamId := setupInitialSidebarCategories(t, ss) + defer ss.User().PermanentDelete(userId) + + newCategory, err := ss.Channel().CreateSidebarCategory(userId, teamId, &model.SidebarCategoryWithChannels{}) + require.Nil(t, err) + require.NotNil(t, newCategory) + + // Ensure that the category was created properly + res, err := ss.Channel().GetSidebarCategories(userId, teamId) + require.Nil(t, err) + require.Len(t, res.Categories, 4) + + // Then delete it and confirm that was done correctly + err = ss.Channel().DeleteSidebarCategory(newCategory.Id) + assert.Nil(t, err) + + res, err = ss.Channel().GetSidebarCategories(userId, teamId) + require.Nil(t, err) + require.Len(t, res.Categories, 3) + }) + + t.Run("should correctly remove a category and its channels", func(t *testing.T) { + userId, teamId := setupInitialSidebarCategories(t, ss) + defer ss.User().PermanentDelete(userId) + + user := &model.User{ + Id: userId, + } + + // Create some channels + channel1, nErr := ss.Channel().Save(&model.Channel{ + Name: model.NewId(), + TeamId: teamId, + Type: model.CHANNEL_OPEN, + }, 1000) + require.Nil(t, nErr) + defer ss.Channel().PermanentDelete(channel1.Id) + + channel2, nErr := ss.Channel().Save(&model.Channel{ + Name: model.NewId(), + TeamId: teamId, + Type: model.CHANNEL_PRIVATE, + }, 1000) + require.Nil(t, nErr) + defer ss.Channel().PermanentDelete(channel2.Id) + + dmChannel1, nErr := ss.Channel().CreateDirectChannel(user, &model.User{ + Id: model.NewId(), + }) + require.Nil(t, nErr) + defer ss.Channel().PermanentDelete(dmChannel1.Id) + + // Assign some of those channels to a custom category + newCategory, err := ss.Channel().CreateSidebarCategory(userId, teamId, &model.SidebarCategoryWithChannels{ + Channels: []string{channel1.Id, channel2.Id, dmChannel1.Id}, + }) + require.Nil(t, err) + require.NotNil(t, newCategory) + + // Ensure that the categories are set up correctly + res, err := ss.Channel().GetSidebarCategories(userId, teamId) + require.Nil(t, err) + require.Len(t, res.Categories, 4) + + require.Equal(t, model.SidebarCategoryCustom, res.Categories[1].Type) + require.Equal(t, []string{channel1.Id, channel2.Id, dmChannel1.Id}, res.Categories[1].Channels) + + // Actually delete the channel + err = ss.Channel().DeleteSidebarCategory(newCategory.Id) + assert.Nil(t, err) + + // Confirm that the category was deleted... + res, err = ss.Channel().GetSidebarCategories(userId, teamId) + assert.Nil(t, err) + assert.Len(t, res.Categories, 3) + + // ...and that the corresponding SidebarChannel entries were deleted + count, countErr := s.GetMaster().SelectInt(` + SELECT + COUNT(*) + FROM + SidebarChannels + WHERE + CategoryId = :CategoryId`, map[string]interface{}{"CategoryId": newCategory.Id}) + require.Nil(t, countErr) + assert.Equal(t, int64(0), count) + }) + + t.Run("should not allow you to remove non-custom categories", func(t *testing.T) { + userId, teamId := setupInitialSidebarCategories(t, ss) + defer ss.User().PermanentDelete(userId) + res, err := ss.Channel().GetSidebarCategories(userId, teamId) + require.Nil(t, err) + require.Len(t, res.Categories, 3) + require.Equal(t, model.SidebarCategoryFavorites, res.Categories[0].Type) + require.Equal(t, model.SidebarCategoryChannels, res.Categories[1].Type) + require.Equal(t, model.SidebarCategoryDirectMessages, res.Categories[2].Type) + + err = ss.Channel().DeleteSidebarCategory(res.Categories[0].Id) + assert.NotNil(t, err) + + err = ss.Channel().DeleteSidebarCategory(res.Categories[1].Id) + assert.NotNil(t, err) + + err = ss.Channel().DeleteSidebarCategory(res.Categories[2].Id) + assert.NotNil(t, err) + }) +} diff --git a/store/storetest/mocks/ChannelStore.go b/store/storetest/mocks/ChannelStore.go index 4f448d545b..0d736e116b 100644 --- a/store/storetest/mocks/ChannelStore.go +++ b/store/storetest/mocks/ChannelStore.go @@ -132,6 +132,22 @@ func (_m *ChannelStore) ClearCaches() { _m.Called() } +// ClearSidebarOnTeamLeave provides a mock function with given fields: userId, teamId +func (_m *ChannelStore) ClearSidebarOnTeamLeave(userId string, teamId string) *model.AppError { + ret := _m.Called(userId, teamId) + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(string, string) *model.AppError); ok { + r0 = rf(userId, teamId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + // CountPostsAfter provides a mock function with given fields: channelId, timestamp, userId func (_m *ChannelStore) CountPostsAfter(channelId string, timestamp int64, userId string) (int, *model.AppError) { ret := _m.Called(channelId, timestamp, userId) @@ -178,6 +194,45 @@ func (_m *ChannelStore) CreateDirectChannel(userId *model.User, otherUserId *mod return r0, r1 } +// CreateInitialSidebarCategories provides a mock function with given fields: user, teamId +func (_m *ChannelStore) CreateInitialSidebarCategories(user *model.User, teamId string) error { + ret := _m.Called(user, teamId) + + var r0 error + if rf, ok := ret.Get(0).(func(*model.User, string) error); ok { + r0 = rf(user, teamId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// CreateSidebarCategory provides a mock function with given fields: userId, teamId, newCategory +func (_m *ChannelStore) CreateSidebarCategory(userId string, teamId string, newCategory *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) { + ret := _m.Called(userId, teamId, newCategory) + + var r0 *model.SidebarCategoryWithChannels + if rf, ok := ret.Get(0).(func(string, string, *model.SidebarCategoryWithChannels) *model.SidebarCategoryWithChannels); ok { + r0 = rf(userId, teamId, newCategory) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.SidebarCategoryWithChannels) + } + } + + var r1 *model.AppError + if rf, ok := ret.Get(1).(func(string, string, *model.SidebarCategoryWithChannels) *model.AppError); ok { + r1 = rf(userId, teamId, newCategory) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // Delete provides a mock function with given fields: channelId, time func (_m *ChannelStore) Delete(channelId string, time int64) error { ret := _m.Called(channelId, time) @@ -192,6 +247,22 @@ func (_m *ChannelStore) Delete(channelId string, time int64) error { return r0 } +// DeleteSidebarCategory provides a mock function with given fields: categoryId +func (_m *ChannelStore) DeleteSidebarCategory(categoryId string) *model.AppError { + ret := _m.Called(categoryId) + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(string) *model.AppError); ok { + r0 = rf(categoryId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + // Get provides a mock function with given fields: id, allowFromCache func (_m *ChannelStore) Get(id string, allowFromCache bool) (*model.Channel, error) { ret := _m.Called(id, allowFromCache) @@ -1126,6 +1197,81 @@ func (_m *ChannelStore) GetPublicChannelsForTeam(teamId string, offset int, limi return r0, r1 } +// GetSidebarCategories provides a mock function with given fields: userId, teamId +func (_m *ChannelStore) GetSidebarCategories(userId string, teamId string) (*model.OrderedSidebarCategories, *model.AppError) { + ret := _m.Called(userId, teamId) + + var r0 *model.OrderedSidebarCategories + if rf, ok := ret.Get(0).(func(string, string) *model.OrderedSidebarCategories); ok { + r0 = rf(userId, teamId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.OrderedSidebarCategories) + } + } + + var r1 *model.AppError + if rf, ok := ret.Get(1).(func(string, string) *model.AppError); ok { + r1 = rf(userId, teamId) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetSidebarCategory provides a mock function with given fields: categoryId +func (_m *ChannelStore) GetSidebarCategory(categoryId string) (*model.SidebarCategoryWithChannels, *model.AppError) { + ret := _m.Called(categoryId) + + var r0 *model.SidebarCategoryWithChannels + if rf, ok := ret.Get(0).(func(string) *model.SidebarCategoryWithChannels); ok { + r0 = rf(categoryId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.SidebarCategoryWithChannels) + } + } + + var r1 *model.AppError + if rf, ok := ret.Get(1).(func(string) *model.AppError); ok { + r1 = rf(categoryId) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetSidebarCategoryOrder provides a mock function with given fields: userId, teamId +func (_m *ChannelStore) GetSidebarCategoryOrder(userId string, teamId string) ([]string, *model.AppError) { + ret := _m.Called(userId, teamId) + + var r0 []string + if rf, ok := ret.Get(0).(func(string, string) []string); ok { + r0 = rf(userId, teamId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 *model.AppError + if rf, ok := ret.Get(1).(func(string, string) *model.AppError); ok { + r1 = rf(userId, teamId) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // GetTeamChannels provides a mock function with given fields: teamId func (_m *ChannelStore) GetTeamChannels(teamId string) (*model.ChannelList, *model.AppError) { ret := _m.Called(teamId) @@ -1264,6 +1410,29 @@ func (_m *ChannelStore) MigrateChannelMembers(fromChannelId string, fromUserId s return r0, r1 } +// MigrateFavoritesToSidebarChannels provides a mock function with given fields: lastUserId, runningOrder +func (_m *ChannelStore) MigrateFavoritesToSidebarChannels(lastUserId string, runningOrder int64) (map[string]interface{}, error) { + ret := _m.Called(lastUserId, runningOrder) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(string, int64) map[string]interface{}); ok { + r0 = rf(lastUserId, runningOrder) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, int64) error); ok { + r1 = rf(lastUserId, runningOrder) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // MigratePublicChannels provides a mock function with given fields: func (_m *ChannelStore) MigratePublicChannels() error { ret := _m.Called() @@ -1278,6 +1447,29 @@ func (_m *ChannelStore) MigratePublicChannels() error { return r0 } +// MigrateSidebarCategories provides a mock function with given fields: fromTeamId, fromUserId +func (_m *ChannelStore) MigrateSidebarCategories(fromTeamId string, fromUserId string) (map[string]interface{}, error) { + ret := _m.Called(fromTeamId, fromUserId) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(string, string) map[string]interface{}); ok { + r0 = rf(fromTeamId, fromUserId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(fromTeamId, fromUserId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // PermanentDelete provides a mock function with given fields: channelId func (_m *ChannelStore) PermanentDelete(channelId string) error { ret := _m.Called(channelId) @@ -1822,6 +2014,79 @@ func (_m *ChannelStore) UpdateMultipleMembers(members []*model.ChannelMember) ([ return r0, r1 } +// UpdateSidebarCategories provides a mock function with given fields: userId, teamId, categories +func (_m *ChannelStore) UpdateSidebarCategories(userId string, teamId string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) { + ret := _m.Called(userId, teamId, categories) + + var r0 []*model.SidebarCategoryWithChannels + if rf, ok := ret.Get(0).(func(string, string, []*model.SidebarCategoryWithChannels) []*model.SidebarCategoryWithChannels); ok { + r0 = rf(userId, teamId, categories) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.SidebarCategoryWithChannels) + } + } + + var r1 *model.AppError + if rf, ok := ret.Get(1).(func(string, string, []*model.SidebarCategoryWithChannels) *model.AppError); ok { + r1 = rf(userId, teamId, categories) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// UpdateSidebarCategoryOrder provides a mock function with given fields: userId, teamId, categoryOrder +func (_m *ChannelStore) UpdateSidebarCategoryOrder(userId string, teamId string, categoryOrder []string) *model.AppError { + ret := _m.Called(userId, teamId, categoryOrder) + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(string, string, []string) *model.AppError); ok { + r0 = rf(userId, teamId, categoryOrder) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + +// UpdateSidebarChannelCategoryOnMove provides a mock function with given fields: channel, newTeamId +func (_m *ChannelStore) UpdateSidebarChannelCategoryOnMove(channel *model.Channel, newTeamId string) *model.AppError { + ret := _m.Called(channel, newTeamId) + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(*model.Channel, string) *model.AppError); ok { + r0 = rf(channel, newTeamId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + +// UpdateSidebarChannelsByPreferences provides a mock function with given fields: preferences +func (_m *ChannelStore) UpdateSidebarChannelsByPreferences(preferences *model.Preferences) *model.AppError { + ret := _m.Called(preferences) + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(*model.Preferences) *model.AppError); ok { + r0 = rf(preferences) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + // UserBelongsToChannels provides a mock function with given fields: userId, channelIds func (_m *ChannelStore) UserBelongsToChannels(userId string, channelIds []string) (bool, *model.AppError) { ret := _m.Called(userId, channelIds) diff --git a/store/storetest/mocks/SqlStore.go b/store/storetest/mocks/SqlStore.go index e80ea11416..0e94a7c7dd 100644 --- a/store/storetest/mocks/SqlStore.go +++ b/store/storetest/mocks/SqlStore.go @@ -247,6 +247,20 @@ func (_m *SqlStore) CreateIndexIfNotExists(indexName string, tableName string, c return r0 } +// CreateUniqueCompositeIndexIfNotExists provides a mock function with given fields: indexName, tableName, columnNames +func (_m *SqlStore) CreateUniqueCompositeIndexIfNotExists(indexName string, tableName string, columnNames []string) bool { + ret := _m.Called(indexName, tableName, columnNames) + + var r0 bool + if rf, ok := ret.Get(0).(func(string, string, []string) bool); ok { + r0 = rf(indexName, tableName, columnNames) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + // CreateUniqueIndexIfNotExists provides a mock function with given fields: indexName, tableName, columnName func (_m *SqlStore) CreateUniqueIndexIfNotExists(indexName string, tableName string, columnName string) bool { ret := _m.Called(indexName, tableName, columnName) diff --git a/store/timer_layer.go b/store/timer_layer.go index 93eaa41a89..043674bafc 100644 --- a/store/timer_layer.go +++ b/store/timer_layer.go @@ -552,6 +552,22 @@ func (s *TimerLayerChannelStore) ClearCaches() { } } +func (s *TimerLayerChannelStore) ClearSidebarOnTeamLeave(userId string, teamId string) *model.AppError { + start := timemodule.Now() + + resultVar0 := s.ChannelStore.ClearSidebarOnTeamLeave(userId, teamId) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar0 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.ClearSidebarOnTeamLeave", success, elapsed) + } + return resultVar0 +} + func (s *TimerLayerChannelStore) CountPostsAfter(channelId string, timestamp int64, userId string) (int, *model.AppError) { start := timemodule.Now() @@ -584,6 +600,38 @@ func (s *TimerLayerChannelStore) CreateDirectChannel(userId *model.User, otherUs return resultVar0, resultVar1 } +func (s *TimerLayerChannelStore) CreateInitialSidebarCategories(user *model.User, teamId string) error { + start := timemodule.Now() + + resultVar0 := s.ChannelStore.CreateInitialSidebarCategories(user, teamId) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar0 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.CreateInitialSidebarCategories", success, elapsed) + } + return resultVar0 +} + +func (s *TimerLayerChannelStore) CreateSidebarCategory(userId string, teamId string, newCategory *model.SidebarCategoryWithChannels) (*model.SidebarCategoryWithChannels, *model.AppError) { + start := timemodule.Now() + + resultVar0, resultVar1 := s.ChannelStore.CreateSidebarCategory(userId, teamId, newCategory) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar1 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.CreateSidebarCategory", success, elapsed) + } + return resultVar0, resultVar1 +} + func (s *TimerLayerChannelStore) Delete(channelId string, time int64) error { start := timemodule.Now() @@ -600,6 +648,22 @@ func (s *TimerLayerChannelStore) Delete(channelId string, time int64) error { return resultVar0 } +func (s *TimerLayerChannelStore) DeleteSidebarCategory(categoryId string) *model.AppError { + start := timemodule.Now() + + resultVar0 := s.ChannelStore.DeleteSidebarCategory(categoryId) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar0 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.DeleteSidebarCategory", success, elapsed) + } + return resultVar0 +} + func (s *TimerLayerChannelStore) Get(id string, allowFromCache bool) (*model.Channel, error) { start := timemodule.Now() @@ -1224,6 +1288,54 @@ func (s *TimerLayerChannelStore) GetPublicChannelsForTeam(teamId string, offset return resultVar0, resultVar1 } +func (s *TimerLayerChannelStore) GetSidebarCategories(userId string, teamId string) (*model.OrderedSidebarCategories, *model.AppError) { + start := timemodule.Now() + + resultVar0, resultVar1 := s.ChannelStore.GetSidebarCategories(userId, teamId) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar1 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.GetSidebarCategories", success, elapsed) + } + return resultVar0, resultVar1 +} + +func (s *TimerLayerChannelStore) GetSidebarCategory(categoryId string) (*model.SidebarCategoryWithChannels, *model.AppError) { + start := timemodule.Now() + + resultVar0, resultVar1 := s.ChannelStore.GetSidebarCategory(categoryId) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar1 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.GetSidebarCategory", success, elapsed) + } + return resultVar0, resultVar1 +} + +func (s *TimerLayerChannelStore) GetSidebarCategoryOrder(userId string, teamId string) ([]string, *model.AppError) { + start := timemodule.Now() + + resultVar0, resultVar1 := s.ChannelStore.GetSidebarCategoryOrder(userId, teamId) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar1 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.GetSidebarCategoryOrder", success, elapsed) + } + return resultVar0, resultVar1 +} + func (s *TimerLayerChannelStore) GetTeamChannels(teamId string) (*model.ChannelList, *model.AppError) { start := timemodule.Now() @@ -1409,6 +1521,22 @@ func (s *TimerLayerChannelStore) MigrateChannelMembers(fromChannelId string, fro return resultVar0, resultVar1 } +func (s *TimerLayerChannelStore) MigrateFavoritesToSidebarChannels(lastUserId string, runningOrder int64) (map[string]interface{}, error) { + start := timemodule.Now() + + resultVar0, resultVar1 := s.ChannelStore.MigrateFavoritesToSidebarChannels(lastUserId, runningOrder) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar1 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.MigrateFavoritesToSidebarChannels", success, elapsed) + } + return resultVar0, resultVar1 +} + func (s *TimerLayerChannelStore) MigratePublicChannels() error { start := timemodule.Now() @@ -1425,6 +1553,22 @@ func (s *TimerLayerChannelStore) MigratePublicChannels() error { return resultVar0 } +func (s *TimerLayerChannelStore) MigrateSidebarCategories(fromTeamId string, fromUserId string) (map[string]interface{}, error) { + start := timemodule.Now() + + resultVar0, resultVar1 := s.ChannelStore.MigrateSidebarCategories(fromTeamId, fromUserId) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar1 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.MigrateSidebarCategories", success, elapsed) + } + return resultVar0, resultVar1 +} + func (s *TimerLayerChannelStore) PermanentDelete(channelId string) error { start := timemodule.Now() @@ -1841,6 +1985,70 @@ func (s *TimerLayerChannelStore) UpdateMultipleMembers(members []*model.ChannelM return resultVar0, resultVar1 } +func (s *TimerLayerChannelStore) UpdateSidebarCategories(userId string, teamId string, categories []*model.SidebarCategoryWithChannels) ([]*model.SidebarCategoryWithChannels, *model.AppError) { + start := timemodule.Now() + + resultVar0, resultVar1 := s.ChannelStore.UpdateSidebarCategories(userId, teamId, categories) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar1 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.UpdateSidebarCategories", success, elapsed) + } + return resultVar0, resultVar1 +} + +func (s *TimerLayerChannelStore) UpdateSidebarCategoryOrder(userId string, teamId string, categoryOrder []string) *model.AppError { + start := timemodule.Now() + + resultVar0 := s.ChannelStore.UpdateSidebarCategoryOrder(userId, teamId, categoryOrder) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar0 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.UpdateSidebarCategoryOrder", success, elapsed) + } + return resultVar0 +} + +func (s *TimerLayerChannelStore) UpdateSidebarChannelCategoryOnMove(channel *model.Channel, newTeamId string) *model.AppError { + start := timemodule.Now() + + resultVar0 := s.ChannelStore.UpdateSidebarChannelCategoryOnMove(channel, newTeamId) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar0 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.UpdateSidebarChannelCategoryOnMove", success, elapsed) + } + return resultVar0 +} + +func (s *TimerLayerChannelStore) UpdateSidebarChannelsByPreferences(preferences *model.Preferences) *model.AppError { + start := timemodule.Now() + + resultVar0 := s.ChannelStore.UpdateSidebarChannelsByPreferences(preferences) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar0 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.UpdateSidebarChannelsByPreferences", success, elapsed) + } + return resultVar0 +} + func (s *TimerLayerChannelStore) UserBelongsToChannels(userId string, channelIds []string) (bool, *model.AppError) { start := timemodule.Now() diff --git a/web/context.go b/web/context.go index 828120de93..0fff4a3162 100644 --- a/web/context.go +++ b/web/context.go @@ -293,6 +293,17 @@ func (c *Context) RequireTeamId() *Context { return c } +func (c *Context) RequireCategoryId() *Context { + if c.Err != nil { + return c + } + + if len(c.Params.CategoryId) != 26 { + c.SetInvalidUrlParam("category_id") + } + return c +} + func (c *Context) RequireInviteId() *Context { if c.Err != nil { return c diff --git a/web/params.go b/web/params.go index af3981a94d..b9690a06fe 100644 --- a/web/params.go +++ b/web/params.go @@ -77,6 +77,7 @@ type Params struct { IncludeDeleted bool FilterAllowReference bool FilterParentTeamPermitted bool + CategoryId string } func ParamsFromRequest(r *http.Request) *Params { @@ -93,6 +94,10 @@ func ParamsFromRequest(r *http.Request) *Params { params.TeamId = val } + if val, ok := props["category_id"]; ok { + params.CategoryId = val + } + if val, ok := props["invite_id"]; ok { params.InviteId = val }