From 5e5769c4ee522ccc7a1e12c05932c21e538e3852 Mon Sep 17 00:00:00 2001 From: Kyriakos Z <3829551+koox00@users.noreply.github.com> Date: Thu, 24 Nov 2022 05:21:40 +0200 Subject: [PATCH] MM-45317: global drafts endpoints and ws events (#20614) * MM-23881: global drafts endpoints and ws events Adds endpoints: - create/update drafts - delete draft - get drafts Adds WS events: - draft_updated - draft_created - draft_deleted * Ordering and WS event name fixes * Adds PostID to the drafts table In the future the drafts will include edited posts, this commit adds the post id in the combined pkey of the table. * Fixes route for deleting a thread draft * Fixes failed checks * Fixes migrations * Fixes migration * Extract translation strings * Removes PostID since we won't sync editing posts * Fixes tests * Fixes i18n * Update migrations for global drafts * update branch with latest master changes * Add feature flag for global drafts * Set global drafts feature flag default to true * Added support for files in drafts * Fix failing i18n check * Added support for deleting files in drafts * Revert "Added support for deleting files in drafts" This reverts commit 45dfd04a760359de2e8814d652c9ef46daf994f6. * Triggering new test server * Add config setting 'AllowSyncedDrafts' for syncing drafts with server * Triggering new test server * Triggering new test server * Add guard for config setting and add initial tests * Fix i18n and lint errors * Triggering new test server * Add tests for drafts * fix lint issues * Add tests for model/draft * Triggering new test server * Triggering new test server * Trigger new test server * Address PR comments * Change left join to regular join in GetDraftsForUser * Fix broken test Maybe consider adding an inclDeleted field if we want to get deleted drafts in the future * fix translations * Add store tests for drafts * fix test naming * remove comment * update migrations * set feature flag default to false * update migrations Co-authored-by: Mylon Suren Co-authored-by: Mattermod --- api4/api.go | 5 + api4/drafts.go | 134 ++++++ api4/drafts_test.go | 232 ++++++++++ app/app_iface.go | 6 + app/draft.go | 211 +++++++++ app/draft_test.go | 438 ++++++++++++++++++ app/opentracing/opentracing_layer.go | 132 ++++++ config/client.go | 1 + db/migrations/migrations.list | 4 + .../mysql/000099_create_drafts.down.sql | 1 + .../mysql/000099_create_drafts.up.sql | 12 + .../postgres/000099_create_drafts.down.sql | 1 + .../postgres/000099_create_drafts.up.sql | 12 + i18n/en.json | 68 +++ model/client4.go | 58 +++ model/config.go | 5 + model/draft.go | 101 ++++ model/draft_test.go | 80 ++++ model/feature_flags.go | 3 + model/websocket_message.go | 3 + services/telemetry/telemetry.go | 1 + store/opentracinglayer/opentracinglayer.go | 101 ++++ store/retrylayer/retrylayer.go | 116 +++++ store/retrylayer/retrylayer_test.go | 1 + store/sqlstore/draft_store.go | 240 ++++++++++ store/sqlstore/draft_store_test.go | 350 ++++++++++++++ store/sqlstore/store.go | 6 + store/store.go | 9 + store/storetest/draft_store.go | 13 + store/storetest/mocks/DraftStore.go | 121 +++++ store/storetest/mocks/Store.go | 16 + store/storetest/store.go | 3 + store/timerlayer/timerlayer.go | 91 ++++ 33 files changed, 2575 insertions(+) create mode 100644 api4/drafts.go create mode 100644 api4/drafts_test.go create mode 100644 app/draft.go create mode 100644 app/draft_test.go create mode 100644 db/migrations/mysql/000099_create_drafts.down.sql create mode 100644 db/migrations/mysql/000099_create_drafts.up.sql create mode 100644 db/migrations/postgres/000099_create_drafts.down.sql create mode 100644 db/migrations/postgres/000099_create_drafts.up.sql create mode 100644 model/draft.go create mode 100644 model/draft_test.go create mode 100644 store/sqlstore/draft_store.go create mode 100644 store/sqlstore/draft_store_test.go create mode 100644 store/storetest/draft_store.go create mode 100644 store/storetest/mocks/DraftStore.go diff --git a/api4/api.go b/api4/api.go index 28114af4fe..ea4b72119a 100644 --- a/api4/api.go +++ b/api4/api.go @@ -139,6 +139,8 @@ type Routes struct { InsightsForUser *mux.Router // 'api/v4/users/me/top' Usage *mux.Router // 'api/v4/usage' + + Drafts *mux.Router // 'api/v4/drafts' } type API struct { @@ -265,6 +267,8 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Usage = api.BaseRoutes.APIRoot.PathPrefix("/usage").Subrouter() + api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter() + api.InitUser() api.InitBot() api.InitTeam() @@ -308,6 +312,7 @@ func Init(srv *app.Server) (*API, error) { api.InitExport() api.InitInsights() api.InitUsage() + api.InitDrafts() if err := api.InitGraphQL(); err != nil { return nil, err } diff --git a/api4/drafts.go b/api4/drafts.go new file mode 100644 index 0000000000..2dd6210c72 --- /dev/null +++ b/api4/drafts.go @@ -0,0 +1,134 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" +) + +func (api *API) InitDrafts() { + api.BaseRoutes.Drafts.Handle("", api.APISessionRequired(upsertDraft)).Methods("POST") + + api.BaseRoutes.TeamForUser.Handle("/drafts", api.APISessionRequired(getDrafts)).Methods("GET") + + api.BaseRoutes.ChannelForUser.Handle("/drafts/{thread_id:[A-Za-z0-9]+}", api.APISessionRequired(deleteDraft)).Methods("DELETE") + api.BaseRoutes.ChannelForUser.Handle("/drafts", api.APISessionRequired(deleteDraft)).Methods("DELETE") +} + +func upsertDraft(c *Context, w http.ResponseWriter, r *http.Request) { + + if !*c.App.Config().ServiceSettings.AllowSyncedDrafts { + c.Err = model.NewAppError("upsertDraft", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + var draft model.Draft + if jsonErr := json.NewDecoder(r.Body).Decode(&draft); jsonErr != nil { + c.SetInvalidParam("draft") + return + } + + draft.DeleteAt = 0 + draft.UserId = c.AppContext.Session().UserId + connectionID := r.Header.Get(model.ConnectionId) + + hasPermission := false + + if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), draft.ChannelId, model.PermissionCreatePost) { + hasPermission = true + } else if channel, err := c.App.GetChannel(c.AppContext, draft.ChannelId); err == nil { + // Temporary permission check method until advanced permissions, please do not copy + if channel.Type == model.ChannelTypeOpen && c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionCreatePostPublic) { + hasPermission = true + } + } + + if !hasPermission { + c.SetPermissionError(model.PermissionCreatePost) + return + } + + dt, err := c.App.UpsertDraft(c.AppContext, &draft, connectionID) + if err != nil { + c.Err = err + return + } + + w.WriteHeader(http.StatusCreated) + + if err := json.NewEncoder(w).Encode(dt); err != nil { + mlog.Warn("Error while writing response", mlog.Err(err)) + } +} + +func getDrafts(c *Context, w http.ResponseWriter, r *http.Request) { + if c.Err != nil { + return + } + + if !*c.App.Config().ServiceSettings.AllowSyncedDrafts { + c.Err = model.NewAppError("getDrafts", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + hasPermission := false + + if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) { + hasPermission = true + } + + if !hasPermission { + c.SetPermissionError(model.PermissionCreatePost) + return + } + + drafts, err := c.App.GetDraftsForUser(c.AppContext.Session().UserId, c.Params.TeamId) + if err != nil { + c.Err = err + return + } + + if err := json.NewEncoder(w).Encode(drafts); err != nil { + mlog.Warn("Error while writing response", mlog.Err(err)) + } +} + +func deleteDraft(c *Context, w http.ResponseWriter, r *http.Request) { + if c.Err != nil { + return + } + + if !*c.App.Config().ServiceSettings.AllowSyncedDrafts { + c.Err = model.NewAppError("deleteDraft", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + rootID := "" + + connectionID := r.Header.Get(model.ConnectionId) + + if c.Params.ThreadId != "" { + rootID = c.Params.ThreadId + } + + userID := c.AppContext.Session().UserId + channelID := c.Params.ChannelId + + draft, err := c.App.GetDraft(userID, channelID, rootID) + if err != nil || c.AppContext.Session().UserId != draft.UserId { + c.SetPermissionError(model.PermissionDeletePost) + return + } + + if _, err := c.App.DeleteDraft(userID, channelID, rootID, connectionID); err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} diff --git a/api4/drafts_test.go b/api4/drafts_test.go new file mode 100644 index 0000000000..879d78bbb8 --- /dev/null +++ b/api4/drafts_test.go @@ -0,0 +1,232 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "os" + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/utils/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpsertDraft(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + // set config + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + client := th.Client + channel := th.BasicChannel + user := th.BasicUser + + draft := &model.Draft{ + CreateAt: 12345, + UpdateAt: 12345, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "original", + } + + // try to upsert draft + draftResp, _, err := client.UpsertDraft(draft) + require.NoError(t, err) + + assert.Equal(t, draft.UserId, draftResp.UserId) + assert.Equal(t, draft.Message, draftResp.Message) + assert.Equal(t, draft.ChannelId, draftResp.ChannelId) + + // upload file + sent, err := testutils.ReadTestFile("test.png") + require.NoError(t, err) + + fileResp, _, err := client.UploadFile(sent, channel.Id, "test.png") + require.NoError(t, err) + + draftWithFiles := draft + draftWithFiles.FileIds = []string{fileResp.FileInfos[0].Id} + + // try to upsert draft with file + draftResp, _, err = client.UpsertDraft(draftWithFiles) + require.NoError(t, err) + + assert.Equal(t, draftWithFiles.UserId, draftResp.UserId) + assert.Equal(t, draftWithFiles.Message, draftResp.Message) + assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds) + + // try to upsert draft for invalid channel + draftInvalidChannel := draft + draftInvalidChannel.ChannelId = "12345" + + _, resp, err := client.UpsertDraft(draft) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // try to upsert draft without config setting set to true + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + _, resp, err = client.UpsertDraft(draft) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) +} + +func TestGetDrafts(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + client := th.Client + channel1 := th.BasicChannel + channel2 := th.BasicChannel2 + user := th.BasicUser + team := th.BasicTeam + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel1.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 11111, + UpdateAt: 32222, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + // upsert draft1 + _, _, err := client.UpsertDraft(draft1) + require.NoError(t, err) + + // upsert draft2 + _, _, err = client.UpsertDraft(draft2) + require.NoError(t, err) + + // try to get drafts + draftResp, _, err := client.GetDrafts(user.Id, team.Id) + require.NoError(t, err) + + assert.Equal(t, draft2.UserId, draftResp[0].UserId) + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.UserId, draftResp[1].UserId) + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + + assert.Len(t, draftResp, 2) + + // try to get drafts on invalid team + _, resp, err := client.GetDrafts(user.Id, "12345") + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // try to get drafts when config is turned off + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + _, resp, err = client.GetDrafts(user.Id, team.Id) + require.Error(t, err) + CheckNotImplementedStatus(t, resp) +} + +func TestDeleteDraft(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + client := th.Client + channel1 := th.BasicChannel + channel2 := th.BasicChannel2 + user := th.BasicUser + team := th.BasicTeam + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel1.Id, + Message: "draft1", + RootId: "", + } + + draft2 := &model.Draft{ + CreateAt: 11111, + UpdateAt: 32222, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + RootId: model.NewId(), + } + + // upsert draft1 + _, _, err := client.UpsertDraft(draft1) + require.NoError(t, err) + + // upsert draft2 + _, _, err = client.UpsertDraft(draft2) + require.NoError(t, err) + + //get drafts + draftResp, _, err := client.GetDrafts(user.Id, team.Id) + require.NoError(t, err) + + assert.Equal(t, draft2.UserId, draftResp[0].UserId) + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.UserId, draftResp[1].UserId) + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + + // try to delete draft1 + _, _, err = client.DeleteDraft(user.Id, channel1.Id, draft1.RootId) + require.NoError(t, err) + + //get drafts + draftResp, _, err = client.GetDrafts(user.Id, team.Id) + require.NoError(t, err) + + assert.Equal(t, draft2.UserId, draftResp[0].UserId) + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + assert.Len(t, draftResp, 1) +} diff --git a/app/app_iface.go b/app/app_iface.go index 352ece5283..e3c02a0d80 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -474,6 +474,7 @@ type AppIface interface { CreateChannelWithUser(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError) CreateCommand(cmd *model.Command) (*model.Command, *model.AppError) CreateCommandWebhook(commandID string, args *model.CommandArgs) (*model.CommandWebhook, *model.AppError) + CreateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) CreateEmoji(c request.CTX, sessionUserId string, emoji *model.Emoji, multiPartImageData *multipart.Form) (*model.Emoji, *model.AppError) CreateGroup(group *model.Group) (*model.Group, *model.AppError) CreateGroupChannel(c request.CTX, userIDs []string, creatorId string) (*model.Channel, *model.AppError) @@ -515,6 +516,7 @@ type AppIface interface { DeleteBrandImage() *model.AppError DeleteChannel(c request.CTX, channel *model.Channel, userID string) *model.AppError DeleteCommand(commandID string) *model.AppError + DeleteDraft(userID, channelID, rootID, connectionID string) (*model.Draft, *model.AppError) DeleteEmoji(c request.CTX, emoji *model.Emoji) *model.AppError DeleteEphemeralPost(userID, postID string) DeleteExport(name string) *model.AppError @@ -626,6 +628,8 @@ type AppIface interface { GetCustomStatus(userID string) (*model.CustomStatus, *model.AppError) GetDefaultProfileImage(user *model.User) ([]byte, *model.AppError) GetDeletedChannels(c request.CTX, teamID string, offset int, limit int, userID string) (model.ChannelList, *model.AppError) + GetDraft(userID, channelID, rootID string) (*model.Draft, *model.AppError) + GetDraftsForUser(userID, teamID string) ([]*model.Draft, *model.AppError) GetEmoji(c request.CTX, emojiId string) (*model.Emoji, *model.AppError) GetEmojiByName(c request.CTX, emojiName string) (*model.Emoji, *model.AppError) GetEmojiImage(c request.CTX, emojiId string) ([]byte, string, *model.AppError) @@ -1096,6 +1100,7 @@ type AppIface interface { UpdateChannelPrivacy(c request.CTX, oldChannel *model.Channel, user *model.User) (*model.Channel, *model.AppError) UpdateCommand(oldCmd, updatedCmd *model.Command) (*model.Command, *model.AppError) UpdateConfig(f func(*model.Config)) + UpdateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) UpdateEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post UpdateExpiredDNDStatuses() ([]*model.Status, error) UpdateGroup(group *model.Group) (*model.Group, *model.AppError) @@ -1141,6 +1146,7 @@ type AppIface interface { UpdateUserRolesWithUser(c request.CTX, user *model.User, newRoles string, sendWebSocketEvent bool) (*model.User, *model.AppError) UploadData(c *request.Context, us *model.UploadSession, rd io.Reader) (*model.FileInfo, *model.AppError) UploadEmojiImage(c request.CTX, id string, imageData *multipart.FileHeader) *model.AppError + UpsertDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) UpsertGroupMembers(groupID string, userIDs []string) ([]*model.GroupMember, *model.AppError) UpsertGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) diff --git a/app/draft.go b/app/draft.go new file mode 100644 index 0000000000..86786c909f --- /dev/null +++ b/app/draft.go @@ -0,0 +1,211 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/mattermost/mattermost-server/v6/app/request" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/store" +) + +func (a *App) GetDraft(userID, channelID, rootID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("GetDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + draft, err := a.Srv().Store().Draft().Get(userID, channelID, rootID) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return nil, model.NewAppError("GetDraft", "app.draft.get.app_error", nil, nfErr.Error(), http.StatusNotFound) + default: + return nil, model.NewAppError("GetDraft", "app.draft.get.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + + return draft, nil +} + +func (a *App) UpsertDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("UpsertDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + dt, dErr := a.Srv().Store().Draft().Get(draft.UserId, draft.ChannelId, draft.RootId) + var notFoundErr *store.ErrNotFound + if dErr != nil && !errors.As(dErr, ¬FoundErr) { + return nil, model.NewAppError("UpsertDraft", "app.select_error", nil, dErr.Error(), http.StatusInternalServerError) + } + + var err *model.AppError + if dt == nil { + dt, err = a.CreateDraft(c, draft, connectionID) + if err != nil { + return nil, err + } + } else { + dt, err = a.UpdateDraft(c, draft, connectionID) + if err != nil { + return nil, err + } + } + + return dt, nil +} + +func (a *App) CreateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("CreateDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + // Check that channel exists and has not been deleted + channel, errCh := a.Srv().Store().Channel().Get(draft.ChannelId, true) + if errCh != nil { + err := model.NewAppError("CreateDraft", "api.context.invalid_param.app_error", map[string]interface{}{"Name": "draft.channel_id"}, errCh.Error(), http.StatusBadRequest) + return nil, err + } + + if channel.DeleteAt != 0 { + err := model.NewAppError("CreateDraft", "api.draft.create_draft.can_not_draft_to_deleted.error", nil, "", http.StatusBadRequest) + return nil, err + } + + _, nErr := a.Srv().Store().User().Get(context.Background(), draft.UserId) + if nErr != nil { + return nil, model.NewAppError("CreateDraft", "app.user.get.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + dt, nErr := a.Srv().Store().Draft().Save(draft) + if nErr != nil { + return nil, model.NewAppError("CreateDraft", "app.draft.save.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + dt = a.prepareDraftWithFileInfos(draft.UserId, dt) + + message := model.NewWebSocketEvent(model.WebsocketEventDraftCreated, "", dt.ChannelId, dt.UserId, nil, connectionID) + draftJSON, jsonErr := json.Marshal(dt) + if jsonErr != nil { + mlog.Warn("Failed to encode draft to JSON", mlog.Err(jsonErr)) + } + message.Add("draft", string(draftJSON)) + a.Publish(message) + + return dt, nil +} + +func (a *App) UpdateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts { + return nil, model.NewAppError("UpsertDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + // Check that channel exists and has not been deleted + channel, errCh := a.Srv().Store().Channel().Get(draft.ChannelId, true) + if errCh != nil { + err := model.NewAppError("UpdateDraft", "api.context.invalid_param.app_error", map[string]interface{}{"Name": "draft.channel_id"}, errCh.Error(), http.StatusBadRequest) + return nil, err + } + + if channel.DeleteAt != 0 { + err := model.NewAppError("UpdateDraft", "api.draft.create_draft.can_not_draft_to_deleted.error", nil, "", http.StatusBadRequest) + return nil, err + } + + _, nErr := a.Srv().Store().User().Get(context.Background(), draft.UserId) + if nErr != nil { + return nil, model.NewAppError("UpdateDraft", "app.user.get.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + dt, nErr := a.Srv().Store().Draft().Update(draft) + if nErr != nil { + return nil, model.NewAppError("UpdateDraft", "app.draft.update.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + dt = a.prepareDraftWithFileInfos(draft.UserId, dt) + + message := model.NewWebSocketEvent(model.WebsocketEventDraftUpdated, "", draft.ChannelId, draft.UserId, nil, connectionID) + draftJSON, jsonErr := json.Marshal(dt) + if jsonErr != nil { + mlog.Warn("Failed to encode draft to JSON", mlog.Err(jsonErr)) + } + message.Add("draft", string(draftJSON)) + a.Publish(message) + + return dt, nil +} + +func (a *App) GetDraftsForUser(userID, teamID string) ([]*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("GetDraftsForUser", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + drafts, err := a.Srv().Store().Draft().GetDraftsForUser(userID, teamID) + + if err != nil { + return nil, model.NewAppError("GetDraftsForUser", "app.draft.get_drafts.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + for _, draft := range drafts { + a.prepareDraftWithFileInfos(userID, draft) + } + return drafts, nil +} + +func (a *App) prepareDraftWithFileInfos(userID string, draft *model.Draft) *model.Draft { + if fileInfos, err := a.getFileInfosForDraft(draft); err != nil { + mlog.Error("Failed to get files for a user's drafts", mlog.String("user_id", userID), mlog.Err(err)) + } else { + draft.Metadata = &model.PostMetadata{} + draft.Metadata.Files = fileInfos + } + + return draft +} + +func (a *App) getFileInfosForDraft(draft *model.Draft) ([]*model.FileInfo, *model.AppError) { + if len(draft.FileIds) == 0 { + return nil, nil + } + + fileInfos, err := a.Srv().Store().FileInfo().GetByIds(draft.FileIds) + if err != nil { + return nil, model.NewAppError("GetFileInfosForDraft", "app.draft.get_for_draft.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + a.generateMiniPreviewForInfos(fileInfos) + + return fileInfos, nil +} + +func (a *App) DeleteDraft(userID, channelID, rootID, connectionID string) (*model.Draft, *model.AppError) { + if !a.Config().FeatureFlags.GlobalDrafts || !*a.Config().ServiceSettings.AllowSyncedDrafts { + return nil, model.NewAppError("DeleteDraft", "app.draft.feature_disabled", nil, "", http.StatusNotImplemented) + } + + draft, nErr := a.Srv().Store().Draft().Get(userID, channelID, rootID) + if nErr != nil { + return nil, model.NewAppError("DeleteDraft", "app.draft.get.app_error", nil, nErr.Error(), http.StatusBadRequest) + } + + if err := a.Srv().Store().Draft().Delete(userID, channelID, rootID); err != nil { + return nil, model.NewAppError("DeleteDraft", "app.draft.delete.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + draftJSON, jsonErr := json.Marshal(draft) + if jsonErr != nil { + mlog.Warn("Failed to encode draft to JSON") + } + + message := model.NewWebSocketEvent(model.WebsocketEventDraftDeleted, "", draft.ChannelId, draft.UserId, nil, connectionID) + message.Add("draft", string(draftJSON)) + a.Publish(message) + + return draft, nil +} diff --git a/app/draft_test.go b/app/draft_test.go new file mode 100644 index 0000000000..fc543de443 --- /dev/null +++ b/app/draft_test.go @@ -0,0 +1,438 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "os" + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/utils/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetDraft(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + + draft := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft", + } + + _, upsertDraftErr := th.App.UpsertDraft(th.Context, draft, "") + assert.Nil(t, upsertDraftErr) + + t.Run("get draft", func(t *testing.T) { + draftResp, err := th.App.GetDraft(user.Id, channel.Id, "") + assert.Nil(t, err) + + assert.Equal(t, draft.Message, draftResp.Message) + assert.Equal(t, draft.ChannelId, draftResp.ChannelId) + }) + + t.Run("get draft feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.GetDraft(user.Id, channel.Id, "") + assert.NotNil(t, err) + }) +} + +func TestUpsertDraft(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00002, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft2", + } + + _, createDraftErr := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, createDraftErr) + + t.Run("upsert draft", func(t *testing.T) { + draftResp, err := th.App.UpsertDraft(th.Context, draft2, "") + assert.Nil(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + assert.Equal(t, draft2.CreateAt, draftResp.CreateAt) + + assert.NotEqual(t, draft1.UpdateAt, draftResp.UpdateAt) + }) + + t.Run("upsert draft feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.UpsertDraft(th.Context, draft1, "") + assert.NotNil(t, err) + }) +} + +func TestCreateDraft(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + channel2 := th.CreateChannel(th.Context, th.BasicTeam) + th.AddUserToChannel(user, channel2) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft", + } + + draft2 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + t.Run("create draft", func(t *testing.T) { + draftResp, err := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + }) + + t.Run("create draft with files", func(t *testing.T) { + // upload file + sent, readFileErr := testutils.ReadTestFile("test.png") + require.NoError(t, readFileErr) + + fileResp, uploadFileErr := th.App.UploadFile(th.Context, sent, channel.Id, "test.png") + assert.Nil(t, uploadFileErr) + + draftWithFiles := draft2 + draftWithFiles.FileIds = []string{fileResp.Id} + + draftResp, err := th.App.CreateDraft(th.Context, draftWithFiles, "") + assert.Nil(t, err) + + assert.Equal(t, draftWithFiles.Message, draftResp.Message) + assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds) + }) + + t.Run("create draft feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.CreateDraft(th.Context, draft1, "") + assert.NotNil(t, err) + }) +} + +func TestUpdateDraft(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00002, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft2", + } + + _, createDraftErr := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, createDraftErr) + + t.Run("update draft", func(t *testing.T) { + draftResp, err := th.App.UpdateDraft(th.Context, draft2, "") + assert.Nil(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + + assert.NotEqual(t, draft1.UpdateAt, draftResp.UpdateAt) + }) + + t.Run("update draft with files", func(t *testing.T) { + // upload file + sent, readFileErr := testutils.ReadTestFile("test.png") + require.NoError(t, readFileErr) + + fileResp, uploadFileErr := th.App.UploadFile(th.Context, sent, channel.Id, "test.png") + assert.Nil(t, uploadFileErr) + + draftWithFiles := draft1 + draftWithFiles.FileIds = []string{fileResp.Id} + + draftResp, err := th.App.UpdateDraft(th.Context, draft1, "") + assert.Nil(t, err) + + assert.Equal(t, draftWithFiles.Message, draftResp.Message) + assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds) + }) + + t.Run("create draft feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.UpdateDraft(th.Context, draft1, "") + assert.NotNil(t, err) + }) +} + +func TestGetDraftsForUser(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + channel2 := th.CreateChannel(th.Context, th.BasicTeam) + th.AddUserToChannel(user, channel2) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, createDraftErr1 := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, createDraftErr1) + + _, createDraftErr2 := th.App.CreateDraft(th.Context, draft2, "") + assert.Nil(t, createDraftErr2) + + t.Run("get drafts", func(t *testing.T) { + draftResp, err := th.App.GetDraftsForUser(user.Id, th.BasicTeam.Id) + assert.Nil(t, err) + + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + }) + + t.Run("get drafts with files", func(t *testing.T) { + // upload file + sent, readFileErr := testutils.ReadTestFile("test.png") + require.NoError(t, readFileErr) + + fileResp, updateDraftErr := th.App.UploadFile(th.Context, sent, channel.Id, "test.png") + assert.Nil(t, updateDraftErr) + + draftWithFiles := draft1 + draftWithFiles.FileIds = []string{fileResp.Id} + + draftResp, updateDraftErr := th.App.UpdateDraft(th.Context, draft1, "") + assert.Nil(t, updateDraftErr) + + assert.Equal(t, draftWithFiles.Message, draftResp.Message) + assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds) + + draftsWithFilesResp, err := th.App.GetDraftsForUser(user.Id, th.BasicTeam.Id) + assert.Nil(t, err) + + assert.Equal(t, draftWithFiles.Message, draftsWithFilesResp[0].Message) + assert.Equal(t, draftWithFiles.ChannelId, draftsWithFilesResp[0].ChannelId) + assert.ElementsMatch(t, draftWithFiles.FileIds, draftsWithFilesResp[0].FileIds) + + assert.Equal(t, fileResp.Name, draftsWithFilesResp[0].Metadata.Files[0].Name) + + assert.Len(t, draftsWithFilesResp, 2) + }) + + t.Run("get drafts feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.GetDraftsForUser(user.Id, th.BasicTeam.Id) + assert.NotNil(t, err) + }) +} + +func TestDeleteDraft(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Server.platform.SetConfigReadOnlyFF(false) + defer th.Server.platform.SetConfigReadOnlyFF(true) + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + user := th.BasicUser + channel := th.BasicChannel + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + _, createDraftErr := th.App.CreateDraft(th.Context, draft1, "") + assert.Nil(t, createDraftErr) + + t.Run("delete draft", func(t *testing.T) { + draftResp, err := th.App.DeleteDraft(user.Id, channel.Id, "", "") + assert.Nil(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + }) + + t.Run("get drafts feature flag", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_GLOBALDRAFTS", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_GLOBALDRAFTS") + os.Setenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS", "false") + defer os.Unsetenv("MM_SERVICESETTINGS_ALLOWSYNCEDDRAFTS") + + th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = false }) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false }) + + defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.GlobalDrafts = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true }) + + _, err := th.App.DeleteDraft(user.Id, channel.Id, "", "") + assert.NotNil(t, err) + }) +} diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index f4ac4fbb51..63a3389735 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -2007,6 +2007,28 @@ func (a *OpenTracingAppLayer) CreateDefaultMemberships(c *request.Context, param return resultVar0 } +func (a *OpenTracingAppLayer) CreateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateDraft") + + 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.CreateDraft(c, draft, connectionID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) CreateEmoji(c request.CTX, sessionUserId string, emoji *model.Emoji, multiPartImageData *multipart.Form) (*model.Emoji, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateEmoji") @@ -2992,6 +3014,28 @@ func (a *OpenTracingAppLayer) DeleteCommand(commandID string) *model.AppError { return resultVar0 } +func (a *OpenTracingAppLayer) DeleteDraft(userID string, channelID string, rootID string, connectionID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteDraft") + + 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.DeleteDraft(userID, channelID, rootID, connectionID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) DeleteEmoji(c request.CTX, emoji *model.Emoji) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteEmoji") @@ -5848,6 +5892,50 @@ func (a *OpenTracingAppLayer) GetDeletedChannels(c request.CTX, teamID string, o return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetDraft(userID string, channelID string, rootID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetDraft") + + 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.GetDraft(userID, channelID, rootID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetDraftsForUser") + + 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.GetDraftsForUser(userID, teamID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetEmoji(c request.CTX, emojiId string) (*model.Emoji, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetEmoji") @@ -16968,6 +17056,28 @@ func (a *OpenTracingAppLayer) UpdateDNDStatusOfUsers() { a.app.UpdateDNDStatusOfUsers() } +func (a *OpenTracingAppLayer) UpdateDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateDraft") + + 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.UpdateDraft(c, draft, connectionID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) UpdateEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateEphemeralPost") @@ -18057,6 +18167,28 @@ func (a *OpenTracingAppLayer) UploadFileX(c *request.Context, channelID string, return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) UpsertDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpsertDraft") + + 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.UpsertDraft(c, draft, connectionID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpsertGroupMember") diff --git a/config/client.go b/config/client.go index c6e650578b..7d05dda917 100644 --- a/config/client.go +++ b/config/client.go @@ -132,6 +132,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["EnableCustomGroups"] = "false" props["InsightsEnabled"] = strconv.FormatBool(c.FeatureFlags.InsightsEnabled) props["PostPriority"] = strconv.FormatBool(*c.ServiceSettings.PostPriority) + props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts) if license != nil { props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer) diff --git a/db/migrations/migrations.list b/db/migrations/migrations.list index ba9b1103ba..5ce8502a01 100644 --- a/db/migrations/migrations.list +++ b/db/migrations/migrations.list @@ -196,6 +196,8 @@ db/migrations/mysql/000097_create_posts_priority.down.sql db/migrations/mysql/000097_create_posts_priority.up.sql db/migrations/mysql/000098_create_post_acknowledgements.down.sql db/migrations/mysql/000098_create_post_acknowledgements.up.sql +db/migrations/mysql/000099_create_drafts.down.sql +db/migrations/mysql/000099_create_drafts.up.sql db/migrations/postgres/000001_create_teams.down.sql db/migrations/postgres/000001_create_teams.up.sql db/migrations/postgres/000002_create_team_members.down.sql @@ -392,3 +394,5 @@ db/migrations/postgres/000097_create_posts_priority.down.sql db/migrations/postgres/000097_create_posts_priority.up.sql db/migrations/postgres/000098_create_post_acknowledgements.down.sql db/migrations/postgres/000098_create_post_acknowledgements.up.sql +db/migrations/postgres/000099_create_drafts.down.sql +db/migrations/postgres/000099_create_drafts.up.sql diff --git a/db/migrations/mysql/000099_create_drafts.down.sql b/db/migrations/mysql/000099_create_drafts.down.sql new file mode 100644 index 0000000000..02a72a656f --- /dev/null +++ b/db/migrations/mysql/000099_create_drafts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS Drafts; diff --git a/db/migrations/mysql/000099_create_drafts.up.sql b/db/migrations/mysql/000099_create_drafts.up.sql new file mode 100644 index 0000000000..d580fcb70e --- /dev/null +++ b/db/migrations/mysql/000099_create_drafts.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS Drafts ( + CreateAt bigint(20) DEFAULT NULL, + UpdateAt bigint(20) DEFAULT NULL, + DeleteAt bigint(20) DEFAULT NULL, + UserId varchar(26) NOT NULL, + ChannelId varchar(26) NOT NULL, + RootId varchar(26) DEFAULT '', + Message text, + Props text, + FileIds text, + PRIMARY KEY (UserId, ChannelId, RootId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/db/migrations/postgres/000099_create_drafts.down.sql b/db/migrations/postgres/000099_create_drafts.down.sql new file mode 100644 index 0000000000..d8c5bb3d4f --- /dev/null +++ b/db/migrations/postgres/000099_create_drafts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS drafts; diff --git a/db/migrations/postgres/000099_create_drafts.up.sql b/db/migrations/postgres/000099_create_drafts.up.sql new file mode 100644 index 0000000000..6896cf1b9c --- /dev/null +++ b/db/migrations/postgres/000099_create_drafts.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS drafts ( + createat bigint, + updateat bigint, + deleteat bigint, + userid VARCHAR(26), + channelid VARCHAR(26), + rootid VARCHAR(26) DEFAULT '', + message VARCHAR(65535), + props VARCHAR(8000), + fileids VARCHAR(300), + PRIMARY KEY (userid, channelid, rootid) +); diff --git a/i18n/en.json b/i18n/en.json index d7b4627505..4087b69144 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1642,6 +1642,14 @@ "id": "api.custom_status.set_custom_statuses.update.app_error", "translation": "Failed to update the custom status. Please add either emoji or custom text status or both." }, + { + "id": "api.draft.create_draft.can_not_draft_to_deleted.error", + "translation": "Can not save draft to deleted channel" + }, + { + "id": "api.drafts.disabled.app_error", + "translation": "Drafts feature is disabled." + }, { "id": "api.elasticsearch.test_elasticsearch_settings_nil.app_error", "translation": "Elasticsearch settings has unset values." @@ -4959,6 +4967,34 @@ "id": "app.custom_group.unique_name", "translation": "group name is not unique" }, + { + "id": "app.draft.delete.app_error", + "translation": "Unable to delete the Draft." + }, + { + "id": "app.draft.feature_disabled", + "translation": "Drafts feature is disabled." + }, + { + "id": "app.draft.get.app_error", + "translation": "Unable to get the Draft." + }, + { + "id": "app.draft.get_drafts.app_error", + "translation": "Unable to get user's Drafts." + }, + { + "id": "app.draft.get_for_draft.app_error", + "translation": "Unable to get files for Draft." + }, + { + "id": "app.draft.save.app_error", + "translation": "Unable to save the Draft." + }, + { + "id": "app.draft.update.app_error", + "translation": "Unable to update the Draft." + }, { "id": "app.email.no_rate_limiter.app_error", "translation": "Rate limiter is not set up." @@ -8627,6 +8663,38 @@ "id": "model.config.is_valid.write_timeout.app_error", "translation": "Invalid value for write timeout." }, + { + "id": "model.draft.is_valid.channel_id.app_error", + "translation": "Invalid channel id." + }, + { + "id": "model.draft.is_valid.create_at.app_error", + "translation": "Create at must be a valid time." + }, + { + "id": "model.draft.is_valid.file_ids.app_error", + "translation": "Invalid file ids." + }, + { + "id": "model.draft.is_valid.msg.app_error", + "translation": "Invalid message." + }, + { + "id": "model.draft.is_valid.props.app_error", + "translation": "Invalid props." + }, + { + "id": "model.draft.is_valid.root_id.app_error", + "translation": "Invalid root id." + }, + { + "id": "model.draft.is_valid.update_at.app_error", + "translation": "Update at must be a valid time." + }, + { + "id": "model.draft.is_valid.user_id.app_error", + "translation": "Invalid user id." + }, { "id": "model.emoji.create_at.app_error", "translation": "Create at must be a valid time." diff --git a/model/client4.go b/model/client4.go index b05895859e..1057d0c55b 100644 --- a/model/client4.go +++ b/model/client4.go @@ -42,6 +42,7 @@ const ( StatusFail = "FAIL" StatusUnhealthy = "UNHEALTHY" StatusRemove = "REMOVE" + ConnectionId = "Connection-Id" ClientDir = "client" @@ -433,6 +434,10 @@ func (c *Client4) commandMoveRoute(commandId string) string { return fmt.Sprintf(c.commandsRoute()+"/%v/move", commandId) } +func (c *Client4) draftsRoute() string { + return "/drafts" +} + func (c *Client4) emojisRoute() string { return "/emoji" } @@ -6229,6 +6234,59 @@ func (c *Client4) GetChannelPoliciesForUser(userID string, offset, limit int) (* return &channels, BuildResponse(r), nil } +// Drafts Sections + +// UpsertDraft will create a new draft or update a draft if it already exists +func (c *Client4) UpsertDraft(draft *Draft) (*Draft, *Response, error) { + buf, err := json.Marshal(draft) + if err != nil { + return nil, nil, NewAppError("UpsertDraft", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPostBytes(c.draftsRoute(), buf) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var df Draft + err = json.NewDecoder(r.Body).Decode(&df) + if err != nil { + return nil, nil, NewAppError("UpsertDraft", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &df, BuildResponse(r), err +} + +// GetDrafts will get all drafts for a user +func (c *Client4) GetDrafts(userId, teamId string) ([]*Draft, *Response, error) { + r, err := c.DoAPIGet(c.userRoute(userId)+c.teamRoute(teamId)+"/drafts", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var drafts []*Draft + err = json.NewDecoder(r.Body).Decode(&drafts) + if err != nil { + return nil, nil, NewAppError("GetDrafts", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return drafts, BuildResponse(r), nil +} + +func (c *Client4) DeleteDraft(userId, channelId, rootId string) (*Draft, *Response, error) { + r, err := c.DoAPIDelete(c.userRoute(userId) + c.channelRoute(channelId) + "/drafts") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var df *Draft + err = json.NewDecoder(r.Body).Decode(&df) + if err != nil { + return nil, BuildResponse(r), NewAppError("DeleteDraft", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return df, BuildResponse(r), nil +} + // Commands Section // CreateCommand will create a new command if the user have the right permissions. diff --git a/model/config.go b/model/config.go index a538f7032e..dbb39a987c 100644 --- a/model/config.go +++ b/model/config.go @@ -383,6 +383,7 @@ type ServiceSettings struct { CollapsedThreads *string `access:"experimental_features"` ManagedResourcePaths *string `access:"environment_web_server,write_restrictable,cloud_restrictable"` EnableCustomGroups *bool `access:"site_users_and_teams"` + AllowSyncedDrafts *bool `access:"site_posts"` } func (s *ServiceSettings) SetDefaults(isUpdate bool) { @@ -847,6 +848,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.PostPriority == nil { s.PostPriority = NewBool(true) } + + if s.AllowSyncedDrafts == nil { + s.AllowSyncedDrafts = NewBool(true) + } } type ClusterSettings struct { diff --git a/model/draft.go b/model/draft.go new file mode 100644 index 0000000000..e683959b1b --- /dev/null +++ b/model/draft.go @@ -0,0 +1,101 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "net/http" + "sync" + "unicode/utf8" +) + +type Draft struct { + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + UserId string `json:"user_id"` + ChannelId string `json:"channel_id"` + RootId string `json:"root_id"` + + Message string `json:"message"` + + propsMu sync.RWMutex `db:"-"` // Unexported mutex used to guard Draft.Props. + Props StringInterface `json:"props"` // Deprecated: use GetProps() + FileIds StringArray `json:"file_ids,omitempty"` + Metadata *PostMetadata `json:"metadata,omitempty"` +} + +func (o *Draft) IsValid(maxDraftSize int) *AppError { + if o.CreateAt == 0 { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.create_at.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if o.UpdateAt == 0 { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.update_at.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if !IsValidId(o.UserId) { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.user_id.app_error", nil, "", http.StatusBadRequest) + } + + if !IsValidId(o.ChannelId) { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest) + } + + if !(IsValidId(o.RootId) || o.RootId == "") { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.root_id.app_error", nil, "", http.StatusBadRequest) + } + + if utf8.RuneCountInString(o.Message) > maxDraftSize { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.msg.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if utf8.RuneCountInString(ArrayToJSON(o.FileIds)) > PostFileidsMaxRunes { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.file_ids.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + if utf8.RuneCountInString(StringInterfaceToJSON(o.GetProps())) > PostPropsMaxRunes { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.props.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + return nil +} + +func (o *Draft) SetProps(props StringInterface) { + o.propsMu.Lock() + defer o.propsMu.Unlock() + o.Props = props +} + +func (o *Draft) GetProps() StringInterface { + o.propsMu.RLock() + defer o.propsMu.RUnlock() + return o.Props +} + +func (o *Draft) PreSave() { + if o.CreateAt == 0 { + o.CreateAt = GetMillis() + } + + o.UpdateAt = o.CreateAt + o.PreCommit() +} + +func (o *Draft) PreCommit() { + if o.GetProps() == nil { + o.SetProps(make(map[string]interface{})) + } + + if o.FileIds == nil { + o.FileIds = []string{} + } + + // There's a rare bug where the client sends up duplicate FileIds so protect against that + o.FileIds = RemoveDuplicateStrings(o.FileIds) +} + +func (o *Draft) PreUpdate() { + o.UpdateAt = GetMillis() + o.PreCommit() +} diff --git a/model/draft_test.go b/model/draft_test.go new file mode 100644 index 0000000000..2e931c31dd --- /dev/null +++ b/model/draft_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDraftIsValid(t *testing.T) { + o := Draft{} + maxDraftSize := 10000 + + err := o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.CreateAt = GetMillis() + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.UpdateAt = GetMillis() + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.UserId = NewId() + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.ChannelId = NewId() + o.RootId = "123" + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.RootId = "" + + o.Message = strings.Repeat("0", maxDraftSize+1) + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) + + o.Message = strings.Repeat("0", maxDraftSize) + err = o.IsValid(maxDraftSize) + assert.Nil(t, err) + + o.Message = "test" + err = o.IsValid(maxDraftSize) + assert.Nil(t, err) + + o.FileIds = StringArray{strings.Repeat("0", maxDraftSize+1)} + err = o.IsValid(maxDraftSize) + assert.NotNil(t, err) +} + +func TestDraftPreSave(t *testing.T) { + o := Draft{Message: "test"} + o.PreSave() + + assert.NotEqual(t, 0, o.CreateAt) + + past := GetMillis() - 1 + o = Draft{Message: "test", CreateAt: past} + o.PreSave() + + assert.LessOrEqual(t, o.CreateAt, past) +} + +func TestDraftPreUpdate(t *testing.T) { + o := Draft{Message: "test"} + o.PreUpdate() + + assert.NotEqual(t, 0, o.UpdateAt) + + past := GetMillis() - 1 + o = Draft{Message: "test", UpdateAt: past} + o.PreSave() + + assert.GreaterOrEqual(t, o.UpdateAt, past) +} diff --git a/model/feature_flags.go b/model/feature_flags.go index 29d4c015fd..9331177c57 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -79,6 +79,8 @@ type FeatureFlags struct { ReduceOnBoardingTaskList bool ThreadsEverywhere bool + + GlobalDrafts bool } func (f *FeatureFlags) SetDefaults() { @@ -109,6 +111,7 @@ func (f *FeatureFlags) SetDefaults() { f.AnnualSubscription = false f.ReduceOnBoardingTaskList = false f.ThreadsEverywhere = false + f.GlobalDrafts = false } func (f *FeatureFlags) Plugins() map[string]string { diff --git a/model/websocket_message.go b/model/websocket_message.go index 8cd2ad1961..1ad2a3b8b0 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -76,6 +76,9 @@ const ( WebsocketEventThreadFollowChanged = "thread_follow_changed" WebsocketEventThreadReadChanged = "thread_read_changed" WebsocketFirstAdminVisitMarketplaceStatusReceived = "first_admin_visit_marketplace_status_received" + WebsocketEventDraftCreated = "draft_created" + WebsocketEventDraftUpdated = "draft_updated" + WebsocketEventDraftDeleted = "draft_deleted" WebsocketEventAcknowledgementAdded = "post_acknowledgement_added" WebsocketEventAcknowledgementRemoved = "post_acknowledgement_removed" ) diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index 3e2bba1566..c72759b128 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -450,6 +450,7 @@ func (ts *TelemetryService) trackConfig() { "restrict_link_previews": isDefault(*cfg.ServiceSettings.RestrictLinkPreviews, ""), "enable_custom_groups": *cfg.ServiceSettings.EnableCustomGroups, "post_priority": *cfg.ServiceSettings.PostPriority, + "allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts, }) ts.SendTelemetry(TrackConfigTeam, map[string]any{ diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 38e136d493..61d30cf909 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -27,6 +27,7 @@ type OpenTracingLayer struct { CommandStore store.CommandStore CommandWebhookStore store.CommandWebhookStore ComplianceStore store.ComplianceStore + DraftStore store.DraftStore EmojiStore store.EmojiStore FileInfoStore store.FileInfoStore GroupStore store.GroupStore @@ -93,6 +94,10 @@ func (s *OpenTracingLayer) Compliance() store.ComplianceStore { return s.ComplianceStore } +func (s *OpenTracingLayer) Draft() store.DraftStore { + return s.DraftStore +} + func (s *OpenTracingLayer) Emoji() store.EmojiStore { return s.EmojiStore } @@ -261,6 +266,11 @@ type OpenTracingLayerComplianceStore struct { Root *OpenTracingLayer } +type OpenTracingLayerDraftStore struct { + store.DraftStore + Root *OpenTracingLayer +} + type OpenTracingLayerEmojiStore struct { store.EmojiStore Root *OpenTracingLayer @@ -3228,6 +3238,96 @@ func (s *OpenTracingLayerComplianceStore) Update(compliance *model.Compliance) ( return result, err } +func (s *OpenTracingLayerDraftStore) Delete(userID string, channelID string, rootID string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Delete") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.DraftStore.Delete(userID, channelID, rootID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerDraftStore) Get(userID string, channelID string, rootID string) (*model.Draft, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DraftStore.Get(userID, channelID, rootID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerDraftStore) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.GetDraftsForUser") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DraftStore.GetDraftsForUser(userID, teamID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerDraftStore) Save(d *model.Draft) (*model.Draft, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Save") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DraftStore.Save(d) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerDraftStore) Update(d *model.Draft) (*model.Draft, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "DraftStore.Update") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.DraftStore.Update(d) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerEmojiStore) Delete(emoji *model.Emoji, timestamp int64) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "EmojiStore.Delete") @@ -12681,6 +12781,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.CommandStore = &OpenTracingLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandWebhookStore = &OpenTracingLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.ComplianceStore = &OpenTracingLayerComplianceStore{ComplianceStore: childStore.Compliance(), Root: &newStore} + newStore.DraftStore = &OpenTracingLayerDraftStore{DraftStore: childStore.Draft(), Root: &newStore} newStore.EmojiStore = &OpenTracingLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore} newStore.FileInfoStore = &OpenTracingLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} newStore.GroupStore = &OpenTracingLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore} diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 1da922dec1..e2e17d5e61 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -30,6 +30,7 @@ type RetryLayer struct { CommandStore store.CommandStore CommandWebhookStore store.CommandWebhookStore ComplianceStore store.ComplianceStore + DraftStore store.DraftStore EmojiStore store.EmojiStore FileInfoStore store.FileInfoStore GroupStore store.GroupStore @@ -96,6 +97,10 @@ func (s *RetryLayer) Compliance() store.ComplianceStore { return s.ComplianceStore } +func (s *RetryLayer) Draft() store.DraftStore { + return s.DraftStore +} + func (s *RetryLayer) Emoji() store.EmojiStore { return s.EmojiStore } @@ -264,6 +269,11 @@ type RetryLayerComplianceStore struct { Root *RetryLayer } +type RetryLayerDraftStore struct { + store.DraftStore + Root *RetryLayer +} + type RetryLayerEmojiStore struct { store.EmojiStore Root *RetryLayer @@ -3614,6 +3624,111 @@ func (s *RetryLayerComplianceStore) Update(compliance *model.Compliance) (*model } +func (s *RetryLayerDraftStore) Delete(userID string, channelID string, rootID string) error { + + tries := 0 + for { + err := s.DraftStore.Delete(userID, channelID, rootID) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDraftStore) Get(userID string, channelID string, rootID string) (*model.Draft, error) { + + tries := 0 + for { + result, err := s.DraftStore.Get(userID, channelID, rootID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDraftStore) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, error) { + + tries := 0 + for { + result, err := s.DraftStore.GetDraftsForUser(userID, teamID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDraftStore) Save(d *model.Draft) (*model.Draft, error) { + + tries := 0 + for { + result, err := s.DraftStore.Save(d) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerDraftStore) Update(d *model.Draft) (*model.Draft, error) { + + tries := 0 + for { + result, err := s.DraftStore.Update(d) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerEmojiStore) Delete(emoji *model.Emoji, timestamp int64) error { tries := 0 @@ -14460,6 +14575,7 @@ func New(childStore store.Store) *RetryLayer { newStore.CommandStore = &RetryLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandWebhookStore = &RetryLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.ComplianceStore = &RetryLayerComplianceStore{ComplianceStore: childStore.Compliance(), Root: &newStore} + newStore.DraftStore = &RetryLayerDraftStore{DraftStore: childStore.Draft(), Root: &newStore} newStore.EmojiStore = &RetryLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore} newStore.FileInfoStore = &RetryLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} newStore.GroupStore = &RetryLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore} diff --git a/store/retrylayer/retrylayer_test.go b/store/retrylayer/retrylayer_test.go index 7efbfa899a..701cf962f4 100644 --- a/store/retrylayer/retrylayer_test.go +++ b/store/retrylayer/retrylayer_test.go @@ -54,6 +54,7 @@ func genStore() *mocks.Store { mock.On("UserTermsOfService").Return(&mocks.UserTermsOfServiceStore{}) mock.On("Webhook").Return(&mocks.WebhookStore{}) mock.On("NotifyAdmin").Return(&mocks.NotifyAdminStore{}) + mock.On("Draft").Return(&mocks.DraftStore{}) mock.On("PostPriority").Return(&mocks.PostPriorityStore{}) mock.On("PostAcknowledgement").Return(&mocks.PostAcknowledgementStore{}) return mock diff --git a/store/sqlstore/draft_store.go b/store/sqlstore/draft_store.go new file mode 100644 index 0000000000..fbf4d07e3d --- /dev/null +++ b/store/sqlstore/draft_store.go @@ -0,0 +1,240 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + "sync" + + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/v6/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/store" +) + +type SqlDraftStore struct { + *SqlStore + metrics einterfaces.MetricsInterface + maxDraftSizeOnce sync.Once + maxDraftSizeCached int +} + +func draftSliceColumns() []string { + return []string{"CreateAt", "UpdateAt", "DeleteAt", "Message", "RootId", "ChannelId", "UserId", "FileIds", "Props"} +} + +func draftToSlice(draft *model.Draft) []interface{} { + return []interface{}{ + draft.CreateAt, + draft.UpdateAt, + draft.DeleteAt, + draft.Message, + draft.RootId, + draft.ChannelId, + draft.UserId, + model.ArrayToJSON(draft.FileIds), + model.StringInterfaceToJSON(draft.Props), + } +} + +func newSqlDraftStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.DraftStore { + return &SqlDraftStore{ + SqlStore: sqlStore, + metrics: metrics, + maxDraftSizeCached: model.PostMessageMaxRunesV1, + } +} + +func (s *SqlDraftStore) Get(userId, channelId, rootId string) (*model.Draft, error) { + query := s.getQueryBuilder(). + Select("*"). + From("Drafts"). + Where(sq.Eq{ + "UserId": userId, + "ChannelId": channelId, + "RootId": rootId, + "DeleteAt": 0, + }) + + dt := model.Draft{} + err := s.GetReplicaX().GetBuilder(&dt, query) + + if err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("Draft", channelId) + } + return nil, errors.Wrapf(err, "failed to find draft with channelid = %s", channelId) + } + + return &dt, nil +} + +func (s *SqlDraftStore) Save(draft *model.Draft) (*model.Draft, error) { + draft.PreSave() + maxDraftSize := s.GetMaxDraftSize() + if err := draft.IsValid(maxDraftSize); err != nil { + return nil, err + } + + builder := s.getQueryBuilder().Insert("Drafts").Columns(draftSliceColumns()...).Values(draftToSlice(draft)...) + query, args, err := builder.ToSql() + + if err != nil { + return nil, errors.Wrap(err, "save_draft_tosql") + } + + if _, err = s.GetMasterX().Exec(query, args...); err != nil { + return nil, errors.Wrap(err, "failed to save Draft") + } + + return draft, nil +} + +func (s *SqlDraftStore) Update(draft *model.Draft) (*model.Draft, error) { + draft.PreUpdate() + + maxDraftSize := s.GetMaxDraftSize() + if err := draft.IsValid(maxDraftSize); err != nil { + return nil, err + } + + query := s.getQueryBuilder(). + Update("Drafts"). + Set("UpdateAt", draft.UpdateAt). + Set("Message", draft.Message). + Set("Props", draft.Props). + Set("FileIds", draft.FileIds). + Where(sq.Eq{ + "UserId": draft.UserId, + "ChannelId": draft.ChannelId, + "RootId": draft.RootId, + "DeleteAt": 0, + }) + + sql, args, err := query.ToSql() + + if err != nil { + return nil, errors.Wrapf(err, "failed to convert to sql") + } + + if _, err = s.GetMasterX().Exec(sql, args...); err != nil { + return nil, errors.Wrapf(err, "failed to update Draft with channelid=%s", draft.ChannelId) + } + + return draft, nil +} + +func (s *SqlDraftStore) GetDraftsForUser(userID, teamID string) ([]*model.Draft, error) { + var drafts []*model.Draft + + query := s.getQueryBuilder(). + Select("Drafts.*"). + From("Drafts"). + InnerJoin("ChannelMembers ON ChannelMembers.ChannelId = Drafts.ChannelId"). + Where(sq.And{ + sq.Eq{"Drafts.DeleteAt": 0}, + sq.Eq{"Drafts.UserId": userID}, + sq.Eq{"ChannelMembers.UserId": userID}, + }). + OrderBy("Drafts.UpdateAt DESC") + + if teamID != "" { + query = query. + Join("Channels ON Drafts.ChannelId = Channels.Id"). + Where(sq.Or{ + sq.Eq{"Channels.TeamId": teamID}, + sq.Eq{"Channels.TeamId": ""}, + }) + } + + err := s.GetReplicaX().SelectBuilder(&drafts, query) + + if err != nil { + return nil, errors.Wrap(err, "failed to get user drafts") + } + + return drafts, nil +} + +func (s *SqlDraftStore) Delete(userID, channelID, rootID string) error { + time := model.GetMillis() + query := s.getQueryBuilder(). + Update("Drafts"). + Set("UpdateAt", time). + Set("DeleteAt", time). + Where(sq.Eq{ + "UserId": userID, + "ChannelId": channelID, + "RootId": rootID, + }) + + sql, args, err := query.ToSql() + if err != nil { + return errors.Wrapf(err, "failed to convert to sql") + } + + _, err = s.GetMasterX().Exec(sql, args...) + + if err != nil { + return errors.Wrap(err, "failed to delete Draft") + } + + return nil +} + +// GetMaxDraftSize returns the maximum number of runes that may be stored in a post. +func (s *SqlDraftStore) GetMaxDraftSize() int { + s.maxDraftSizeOnce.Do(func() { + s.maxDraftSizeCached = s.determineMaxDraftSize() + }) + return s.maxDraftSizeCached +} + +func (s *SqlDraftStore) determineMaxDraftSize() int { + var maxDraftSizeBytes int32 + + if s.DriverName() == model.DatabaseDriverPostgres { + // The Draft.Message column in Postgres has historically been VARCHAR(4000), but + // may be manually enlarged to support longer drafts. + if err := s.GetReplicaX().Get(&maxDraftSizeBytes, ` + SELECT + COALESCE(character_maximum_length, 0) + FROM + information_schema.columns + WHERE + table_name = 'drafts' + AND column_name = 'message' + `); err != nil { + mlog.Warn("Unable to determine the maximum supported draft size", mlog.Err(err)) + } + } else if s.DriverName() == model.DatabaseDriverMysql { + // The Draft.Message column in MySQL has historically been TEXT, with a maximum + // limit of 65535. + if err := s.GetReplicaX().Get(&maxDraftSizeBytes, ` + SELECT + COALESCE(CHARACTER_MAXIMUM_LENGTH, 0) + FROM + INFORMATION_SCHEMA.COLUMNS + WHERE + table_schema = DATABASE() + AND table_name = 'Drafts' + AND column_name = 'Message' + LIMIT 0, 1 + `); err != nil { + mlog.Warn("Unable to determine the maximum supported draft size", mlog.Err(err)) + } + } else { + mlog.Warn("No implementation found to determine the maximum supported draft size") + } + + // Assume a worst-case representation of four bytes per rune. + maxDraftSize := int(maxDraftSizeBytes) / 4 + + mlog.Info("Draft.Message has size restrictions", mlog.Int("max_characters", maxDraftSize), mlog.Int32("max_bytes", maxDraftSizeBytes)) + + return maxDraftSize +} diff --git a/store/sqlstore/draft_store_test.go b/store/sqlstore/draft_store_test.go new file mode 100644 index 0000000000..471161d0de --- /dev/null +++ b/store/sqlstore/draft_store_test.go @@ -0,0 +1,350 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + "github.com/mattermost/mattermost-server/v6/store/storetest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDraftStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestDraftStore) +} + +func TestSaveDraft(t *testing.T) { + StoreTest(t, func(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + t.Run("save drafts", func(t *testing.T) { + draftResp, err := ss.Draft().Save(draft1) + assert.NoError(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + draftResp, err = ss.Draft().Save(draft2) + assert.NoError(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + }) + }) +} + +func TestUpdateDraft(t *testing.T) { + StoreTest(t, func(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + t.Run("update drafts", func(t *testing.T) { + draftResp, err := ss.Draft().Update(draft1) + assert.NoError(t, err) + + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + draftResp, err = ss.Draft().Update(draft2) + assert.NoError(t, err) + + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + }) + }) +} + +func TestDeleteDraft(t *testing.T) { + StoreTest(t, func(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, err = ss.Draft().Save(draft1) + require.NoError(t, err) + + _, err = ss.Draft().Save(draft2) + require.NoError(t, err) + + t.Run("delete drafts", func(t *testing.T) { + err := ss.Draft().Delete(user.Id, channel.Id, "") + assert.NoError(t, err) + + err = ss.Draft().Delete(user.Id, channel2.Id, "") + assert.NoError(t, err) + }) + }) +} + +func TestGetDraft(t *testing.T) { + StoreTest(t, func(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, err = ss.Draft().Save(draft1) + require.NoError(t, err) + + _, err = ss.Draft().Save(draft2) + require.NoError(t, err) + + t.Run("get drafts", func(t *testing.T) { + draftResp, err := ss.Draft().Get(user.Id, channel.Id, "") + assert.NoError(t, err) + assert.Equal(t, draft1.Message, draftResp.Message) + assert.Equal(t, draft1.ChannelId, draftResp.ChannelId) + + draftResp, err = ss.Draft().Get(user.Id, channel2.Id, "") + assert.NoError(t, err) + assert.Equal(t, draft2.Message, draftResp.Message) + assert.Equal(t, draft2.ChannelId, draftResp.ChannelId) + }) + }) +} + +func TestGetDraftsForUser(t *testing.T) { + StoreTest(t, func(t *testing.T, ss store.Store) { + user := &model.User{ + Id: model.NewId(), + } + + channel := &model.Channel{ + Id: model.NewId(), + } + channel2 := &model.Channel{ + Id: model.NewId(), + } + + member1 := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + member2 := &model.ChannelMember{ + ChannelId: channel2.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + + _, err := ss.Channel().SaveMember(member1) + require.NoError(t, err) + + _, err = ss.Channel().SaveMember(member2) + require.NoError(t, err) + + draft1 := &model.Draft{ + CreateAt: 00001, + UpdateAt: 00001, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel.Id, + Message: "draft1", + } + + draft2 := &model.Draft{ + CreateAt: 00005, + UpdateAt: 00005, + DeleteAt: 0, + UserId: user.Id, + ChannelId: channel2.Id, + Message: "draft2", + } + + _, err = ss.Draft().Save(draft1) + require.NoError(t, err) + + _, err = ss.Draft().Save(draft2) + require.NoError(t, err) + + t.Run("get drafts", func(t *testing.T) { + draftResp, err := ss.Draft().GetDraftsForUser(user.Id, "") + assert.NoError(t, err) + + assert.Equal(t, draft2.Message, draftResp[0].Message) + assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId) + + assert.Equal(t, draft1.Message, draftResp[1].Message) + assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId) + }) + }) +} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 6a0cdbc51a..4c1bf1f05b 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -108,6 +108,7 @@ type SqlStoreStores struct { UserTermsOfService store.UserTermsOfServiceStore linkMetadata store.LinkMetadataStore sharedchannel store.SharedChannelStore + draft store.DraftStore notifyAdmin store.NotifyAdminStore postPriority store.PostPriorityStore postAcknowledgement store.PostAcknowledgementStore @@ -215,6 +216,7 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS store.stores.scheme = newSqlSchemeStore(store) store.stores.group = newSqlGroupStore(store) store.stores.productNotices = newSqlProductNoticesStore(store) + store.stores.draft = newSqlDraftStore(store, metrics) store.stores.notifyAdmin = newSqlNotifyAdminStore(store) store.stores.postPriority = newSqlPostPriorityStore(store) store.stores.postAcknowledgement = newSqlPostAcknowledgementStore(store) @@ -963,6 +965,10 @@ func (ss *SqlStore) PostPriority() store.PostPriorityStore { return ss.stores.postPriority } +func (ss *SqlStore) Draft() store.DraftStore { + return ss.stores.draft +} + func (ss *SqlStore) PostAcknowledgement() store.PostAcknowledgementStore { return ss.stores.postAcknowledgement } diff --git a/store/store.go b/store/store.go index 4b3f4a94ea..deebac76f6 100644 --- a/store/store.go +++ b/store/store.go @@ -59,6 +59,7 @@ type Store interface { UserTermsOfService() UserTermsOfServiceStore LinkMetadata() LinkMetadataStore SharedChannel() SharedChannelStore + Draft() DraftStore MarkSystemRanUnitTests() Close() LockToMaster() @@ -979,6 +980,14 @@ type PostPriorityStore interface { GetForPosts(ids []string) ([]*model.PostPriority, error) } +type DraftStore interface { + Save(d *model.Draft) (*model.Draft, error) + Get(userID, channelID, rootID string) (*model.Draft, error) + Delete(userID, channelID, rootID string) error + GetDraftsForUser(userID, teamID string) ([]*model.Draft, error) + Update(d *model.Draft) (*model.Draft, error) +} + type PostAcknowledgementStore interface { Get(postID, userID string) (*model.PostAcknowledgement, error) GetForPost(postID string) ([]*model.PostAcknowledgement, error) diff --git a/store/storetest/draft_store.go b/store/storetest/draft_store.go new file mode 100644 index 0000000000..b540d672d3 --- /dev/null +++ b/store/storetest/draft_store.go @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/store" +) + +func TestDraftStore(t *testing.T, ss store.Store, s SqlStore) { +} diff --git a/store/storetest/mocks/DraftStore.go b/store/storetest/mocks/DraftStore.go new file mode 100644 index 0000000000..b7d89b1308 --- /dev/null +++ b/store/storetest/mocks/DraftStore.go @@ -0,0 +1,121 @@ +// Code generated by mockery v2.10.4. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost-server/v6/model" + mock "github.com/stretchr/testify/mock" +) + +// DraftStore is an autogenerated mock type for the DraftStore type +type DraftStore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: userID, channelID, rootID +func (_m *DraftStore) Delete(userID string, channelID string, rootID string) error { + ret := _m.Called(userID, channelID, rootID) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, string) error); ok { + r0 = rf(userID, channelID, rootID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: userID, channelID, rootID +func (_m *DraftStore) Get(userID string, channelID string, rootID string) (*model.Draft, error) { + ret := _m.Called(userID, channelID, rootID) + + var r0 *model.Draft + if rf, ok := ret.Get(0).(func(string, string, string) *model.Draft); ok { + r0 = rf(userID, channelID, rootID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Draft) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = rf(userID, channelID, rootID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetDraftsForUser provides a mock function with given fields: userID, teamID +func (_m *DraftStore) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, error) { + ret := _m.Called(userID, teamID) + + var r0 []*model.Draft + if rf, ok := ret.Get(0).(func(string, string) []*model.Draft); ok { + r0 = rf(userID, teamID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Draft) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(userID, teamID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Save provides a mock function with given fields: d +func (_m *DraftStore) Save(d *model.Draft) (*model.Draft, error) { + ret := _m.Called(d) + + var r0 *model.Draft + if rf, ok := ret.Get(0).(func(*model.Draft) *model.Draft); ok { + r0 = rf(d) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Draft) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.Draft) error); ok { + r1 = rf(d) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: d +func (_m *DraftStore) Update(d *model.Draft) (*model.Draft, error) { + ret := _m.Called(d) + + var r0 *model.Draft + if rf, ok := ret.Get(0).(func(*model.Draft) *model.Draft); ok { + r0 = rf(d) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Draft) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.Draft) error); ok { + r1 = rf(d) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/store/storetest/mocks/Store.go b/store/storetest/mocks/Store.go index cfa20ca980..395cc3e9f1 100644 --- a/store/storetest/mocks/Store.go +++ b/store/storetest/mocks/Store.go @@ -187,6 +187,22 @@ func (_m *Store) Context() context.Context { return r0 } +// Draft provides a mock function with given fields: +func (_m *Store) Draft() store.DraftStore { + ret := _m.Called() + + var r0 store.DraftStore + if rf, ok := ret.Get(0).(func() store.DraftStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.DraftStore) + } + } + + return r0 +} + // DropAllTables provides a mock function with given fields: func (_m *Store) DropAllTables() { _m.Called() diff --git a/store/storetest/store.go b/store/storetest/store.go index 93a85d3ab1..e873ae4994 100644 --- a/store/storetest/store.go +++ b/store/storetest/store.go @@ -54,6 +54,7 @@ type Store struct { LinkMetadataStore mocks.LinkMetadataStore SharedChannelStore mocks.SharedChannelStore ProductNoticesStore mocks.ProductNoticesStore + DraftStore mocks.DraftStore context context.Context NotifyAdminStore mocks.NotifyAdminStore PostPriorityStore mocks.PostPriorityStore @@ -95,6 +96,7 @@ func (s *Store) Role() store.RoleStore { return &s.R func (s *Store) Scheme() store.SchemeStore { return &s.SchemeStore } func (s *Store) TermsOfService() store.TermsOfServiceStore { return &s.TermsOfServiceStore } func (s *Store) UserTermsOfService() store.UserTermsOfServiceStore { return &s.UserTermsOfServiceStore } +func (s *Store) Draft() store.DraftStore { return &s.DraftStore } func (s *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore { return &s.ChannelMemberHistoryStore } @@ -163,6 +165,7 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.ThreadStore, &s.ProductNoticesStore, &s.SharedChannelStore, + &s.DraftStore, &s.NotifyAdminStore, &s.PostPriorityStore, &s.PostAcknowledgementStore, diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 3395ab8b45..86a925e4e4 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -26,6 +26,7 @@ type TimerLayer struct { CommandStore store.CommandStore CommandWebhookStore store.CommandWebhookStore ComplianceStore store.ComplianceStore + DraftStore store.DraftStore EmojiStore store.EmojiStore FileInfoStore store.FileInfoStore GroupStore store.GroupStore @@ -92,6 +93,10 @@ func (s *TimerLayer) Compliance() store.ComplianceStore { return s.ComplianceStore } +func (s *TimerLayer) Draft() store.DraftStore { + return s.DraftStore +} + func (s *TimerLayer) Emoji() store.EmojiStore { return s.EmojiStore } @@ -260,6 +265,11 @@ type TimerLayerComplianceStore struct { Root *TimerLayer } +type TimerLayerDraftStore struct { + store.DraftStore + Root *TimerLayer +} + type TimerLayerEmojiStore struct { store.EmojiStore Root *TimerLayer @@ -2955,6 +2965,86 @@ func (s *TimerLayerComplianceStore) Update(compliance *model.Compliance) (*model return result, err } +func (s *TimerLayerDraftStore) Delete(userID string, channelID string, rootID string) error { + start := time.Now() + + err := s.DraftStore.Delete(userID, channelID, rootID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerDraftStore) Get(userID string, channelID string, rootID string) (*model.Draft, error) { + start := time.Now() + + result, err := s.DraftStore.Get(userID, channelID, rootID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerDraftStore) GetDraftsForUser(userID string, teamID string) ([]*model.Draft, error) { + start := time.Now() + + result, err := s.DraftStore.GetDraftsForUser(userID, teamID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.GetDraftsForUser", success, elapsed) + } + return result, err +} + +func (s *TimerLayerDraftStore) Save(d *model.Draft) (*model.Draft, error) { + start := time.Now() + + result, err := s.DraftStore.Save(d) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.Save", success, elapsed) + } + return result, err +} + +func (s *TimerLayerDraftStore) Update(d *model.Draft) (*model.Draft, error) { + start := time.Now() + + result, err := s.DraftStore.Update(d) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("DraftStore.Update", success, elapsed) + } + return result, err +} + func (s *TimerLayerEmojiStore) Delete(emoji *model.Emoji, timestamp int64) error { start := time.Now() @@ -11424,6 +11514,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.CommandStore = &TimerLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandWebhookStore = &TimerLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.ComplianceStore = &TimerLayerComplianceStore{ComplianceStore: childStore.Compliance(), Root: &newStore} + newStore.DraftStore = &TimerLayerDraftStore{DraftStore: childStore.Draft(), Root: &newStore} newStore.EmojiStore = &TimerLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore} newStore.FileInfoStore = &TimerLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} newStore.GroupStore = &TimerLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore}