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 <mylonsuren@gmail.com>
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Этот коммит содержится в:
Kyriakos Z
2022-11-24 05:21:40 +02:00
коммит произвёл GitHub
родитель 27db854089
Коммит 5e5769c4ee
33 изменённых файлов: 2575 добавлений и 0 удалений

Просмотреть файл

@@ -139,6 +139,8 @@ type Routes struct {
InsightsForUser *mux.Router // 'api/v4/users/me/top' InsightsForUser *mux.Router // 'api/v4/users/me/top'
Usage *mux.Router // 'api/v4/usage' Usage *mux.Router // 'api/v4/usage'
Drafts *mux.Router // 'api/v4/drafts'
} }
type API struct { 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.Usage = api.BaseRoutes.APIRoot.PathPrefix("/usage").Subrouter()
api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter()
api.InitUser() api.InitUser()
api.InitBot() api.InitBot()
api.InitTeam() api.InitTeam()
@@ -308,6 +312,7 @@ func Init(srv *app.Server) (*API, error) {
api.InitExport() api.InitExport()
api.InitInsights() api.InitInsights()
api.InitUsage() api.InitUsage()
api.InitDrafts()
if err := api.InitGraphQL(); err != nil { if err := api.InitGraphQL(); err != nil {
return nil, err return nil, err
} }

134
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)
}

232
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)
}

Просмотреть файл

@@ -474,6 +474,7 @@ type AppIface interface {
CreateChannelWithUser(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError) CreateChannelWithUser(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError)
CreateCommand(cmd *model.Command) (*model.Command, *model.AppError) CreateCommand(cmd *model.Command) (*model.Command, *model.AppError)
CreateCommandWebhook(commandID string, args *model.CommandArgs) (*model.CommandWebhook, *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) CreateEmoji(c request.CTX, sessionUserId string, emoji *model.Emoji, multiPartImageData *multipart.Form) (*model.Emoji, *model.AppError)
CreateGroup(group *model.Group) (*model.Group, *model.AppError) CreateGroup(group *model.Group) (*model.Group, *model.AppError)
CreateGroupChannel(c request.CTX, userIDs []string, creatorId string) (*model.Channel, *model.AppError) CreateGroupChannel(c request.CTX, userIDs []string, creatorId string) (*model.Channel, *model.AppError)
@@ -515,6 +516,7 @@ type AppIface interface {
DeleteBrandImage() *model.AppError DeleteBrandImage() *model.AppError
DeleteChannel(c request.CTX, channel *model.Channel, userID string) *model.AppError DeleteChannel(c request.CTX, channel *model.Channel, userID string) *model.AppError
DeleteCommand(commandID 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 DeleteEmoji(c request.CTX, emoji *model.Emoji) *model.AppError
DeleteEphemeralPost(userID, postID string) DeleteEphemeralPost(userID, postID string)
DeleteExport(name string) *model.AppError DeleteExport(name string) *model.AppError
@@ -626,6 +628,8 @@ type AppIface interface {
GetCustomStatus(userID string) (*model.CustomStatus, *model.AppError) GetCustomStatus(userID string) (*model.CustomStatus, *model.AppError)
GetDefaultProfileImage(user *model.User) ([]byte, *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) 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) GetEmoji(c request.CTX, emojiId string) (*model.Emoji, *model.AppError)
GetEmojiByName(c request.CTX, emojiName 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) 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) UpdateChannelPrivacy(c request.CTX, oldChannel *model.Channel, user *model.User) (*model.Channel, *model.AppError)
UpdateCommand(oldCmd, updatedCmd *model.Command) (*model.Command, *model.AppError) UpdateCommand(oldCmd, updatedCmd *model.Command) (*model.Command, *model.AppError)
UpdateConfig(f func(*model.Config)) 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 UpdateEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post
UpdateExpiredDNDStatuses() ([]*model.Status, error) UpdateExpiredDNDStatuses() ([]*model.Status, error)
UpdateGroup(group *model.Group) (*model.Group, *model.AppError) 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) 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) 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 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) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError)
UpsertGroupMembers(groupID string, userIDs []string) ([]*model.GroupMember, *model.AppError) UpsertGroupMembers(groupID string, userIDs []string) ([]*model.GroupMember, *model.AppError)
UpsertGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) UpsertGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError)

211
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, &notFoundErr) {
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
}

438
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)
})
}

Просмотреть файл

@@ -2007,6 +2007,28 @@ func (a *OpenTracingAppLayer) CreateDefaultMemberships(c *request.Context, param
return resultVar0 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) { func (a *OpenTracingAppLayer) CreateEmoji(c request.CTX, sessionUserId string, emoji *model.Emoji, multiPartImageData *multipart.Form) (*model.Emoji, *model.AppError) {
origCtx := a.ctx origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateEmoji") span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateEmoji")
@@ -2992,6 +3014,28 @@ func (a *OpenTracingAppLayer) DeleteCommand(commandID string) *model.AppError {
return resultVar0 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 { func (a *OpenTracingAppLayer) DeleteEmoji(c request.CTX, emoji *model.Emoji) *model.AppError {
origCtx := a.ctx origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteEmoji") 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 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) { func (a *OpenTracingAppLayer) GetEmoji(c request.CTX, emojiId string) (*model.Emoji, *model.AppError) {
origCtx := a.ctx origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetEmoji") span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetEmoji")
@@ -16968,6 +17056,28 @@ func (a *OpenTracingAppLayer) UpdateDNDStatusOfUsers() {
a.app.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 { func (a *OpenTracingAppLayer) UpdateEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post {
origCtx := a.ctx origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateEphemeralPost") span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateEphemeralPost")
@@ -18057,6 +18167,28 @@ func (a *OpenTracingAppLayer) UploadFileX(c *request.Context, channelID string,
return resultVar0, resultVar1 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) { func (a *OpenTracingAppLayer) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) {
origCtx := a.ctx origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpsertGroupMember") span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpsertGroupMember")

Просмотреть файл

@@ -132,6 +132,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["EnableCustomGroups"] = "false" props["EnableCustomGroups"] = "false"
props["InsightsEnabled"] = strconv.FormatBool(c.FeatureFlags.InsightsEnabled) props["InsightsEnabled"] = strconv.FormatBool(c.FeatureFlags.InsightsEnabled)
props["PostPriority"] = strconv.FormatBool(*c.ServiceSettings.PostPriority) props["PostPriority"] = strconv.FormatBool(*c.ServiceSettings.PostPriority)
props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts)
if license != nil { if license != nil {
props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer) props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer)

Просмотреть файл

@@ -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/000097_create_posts_priority.up.sql
db/migrations/mysql/000098_create_post_acknowledgements.down.sql db/migrations/mysql/000098_create_post_acknowledgements.down.sql
db/migrations/mysql/000098_create_post_acknowledgements.up.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.down.sql
db/migrations/postgres/000001_create_teams.up.sql db/migrations/postgres/000001_create_teams.up.sql
db/migrations/postgres/000002_create_team_members.down.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/000097_create_posts_priority.up.sql
db/migrations/postgres/000098_create_post_acknowledgements.down.sql db/migrations/postgres/000098_create_post_acknowledgements.down.sql
db/migrations/postgres/000098_create_post_acknowledgements.up.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

Просмотреть файл

@@ -0,0 +1 @@
DROP TABLE IF EXISTS Drafts;

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -0,0 +1 @@
DROP TABLE IF EXISTS drafts;

Просмотреть файл

@@ -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)
);

Просмотреть файл

@@ -1642,6 +1642,14 @@
"id": "api.custom_status.set_custom_statuses.update.app_error", "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." "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", "id": "api.elasticsearch.test_elasticsearch_settings_nil.app_error",
"translation": "Elasticsearch settings has unset values." "translation": "Elasticsearch settings has unset values."
@@ -4959,6 +4967,34 @@
"id": "app.custom_group.unique_name", "id": "app.custom_group.unique_name",
"translation": "group name is not unique" "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", "id": "app.email.no_rate_limiter.app_error",
"translation": "Rate limiter is not set up." "translation": "Rate limiter is not set up."
@@ -8627,6 +8663,38 @@
"id": "model.config.is_valid.write_timeout.app_error", "id": "model.config.is_valid.write_timeout.app_error",
"translation": "Invalid value for write timeout." "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", "id": "model.emoji.create_at.app_error",
"translation": "Create at must be a valid time." "translation": "Create at must be a valid time."

Просмотреть файл

@@ -42,6 +42,7 @@ const (
StatusFail = "FAIL" StatusFail = "FAIL"
StatusUnhealthy = "UNHEALTHY" StatusUnhealthy = "UNHEALTHY"
StatusRemove = "REMOVE" StatusRemove = "REMOVE"
ConnectionId = "Connection-Id"
ClientDir = "client" ClientDir = "client"
@@ -433,6 +434,10 @@ func (c *Client4) commandMoveRoute(commandId string) string {
return fmt.Sprintf(c.commandsRoute()+"/%v/move", commandId) return fmt.Sprintf(c.commandsRoute()+"/%v/move", commandId)
} }
func (c *Client4) draftsRoute() string {
return "/drafts"
}
func (c *Client4) emojisRoute() string { func (c *Client4) emojisRoute() string {
return "/emoji" return "/emoji"
} }
@@ -6229,6 +6234,59 @@ func (c *Client4) GetChannelPoliciesForUser(userID string, offset, limit int) (*
return &channels, BuildResponse(r), nil 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 // Commands Section
// CreateCommand will create a new command if the user have the right permissions. // CreateCommand will create a new command if the user have the right permissions.

Просмотреть файл

@@ -383,6 +383,7 @@ type ServiceSettings struct {
CollapsedThreads *string `access:"experimental_features"` CollapsedThreads *string `access:"experimental_features"`
ManagedResourcePaths *string `access:"environment_web_server,write_restrictable,cloud_restrictable"` ManagedResourcePaths *string `access:"environment_web_server,write_restrictable,cloud_restrictable"`
EnableCustomGroups *bool `access:"site_users_and_teams"` EnableCustomGroups *bool `access:"site_users_and_teams"`
AllowSyncedDrafts *bool `access:"site_posts"`
} }
func (s *ServiceSettings) SetDefaults(isUpdate bool) { func (s *ServiceSettings) SetDefaults(isUpdate bool) {
@@ -847,6 +848,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) {
if s.PostPriority == nil { if s.PostPriority == nil {
s.PostPriority = NewBool(true) s.PostPriority = NewBool(true)
} }
if s.AllowSyncedDrafts == nil {
s.AllowSyncedDrafts = NewBool(true)
}
} }
type ClusterSettings struct { type ClusterSettings struct {

101
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()
}

80
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)
}

Просмотреть файл

@@ -79,6 +79,8 @@ type FeatureFlags struct {
ReduceOnBoardingTaskList bool ReduceOnBoardingTaskList bool
ThreadsEverywhere bool ThreadsEverywhere bool
GlobalDrafts bool
} }
func (f *FeatureFlags) SetDefaults() { func (f *FeatureFlags) SetDefaults() {
@@ -109,6 +111,7 @@ func (f *FeatureFlags) SetDefaults() {
f.AnnualSubscription = false f.AnnualSubscription = false
f.ReduceOnBoardingTaskList = false f.ReduceOnBoardingTaskList = false
f.ThreadsEverywhere = false f.ThreadsEverywhere = false
f.GlobalDrafts = false
} }
func (f *FeatureFlags) Plugins() map[string]string { func (f *FeatureFlags) Plugins() map[string]string {

Просмотреть файл

@@ -76,6 +76,9 @@ const (
WebsocketEventThreadFollowChanged = "thread_follow_changed" WebsocketEventThreadFollowChanged = "thread_follow_changed"
WebsocketEventThreadReadChanged = "thread_read_changed" WebsocketEventThreadReadChanged = "thread_read_changed"
WebsocketFirstAdminVisitMarketplaceStatusReceived = "first_admin_visit_marketplace_status_received" WebsocketFirstAdminVisitMarketplaceStatusReceived = "first_admin_visit_marketplace_status_received"
WebsocketEventDraftCreated = "draft_created"
WebsocketEventDraftUpdated = "draft_updated"
WebsocketEventDraftDeleted = "draft_deleted"
WebsocketEventAcknowledgementAdded = "post_acknowledgement_added" WebsocketEventAcknowledgementAdded = "post_acknowledgement_added"
WebsocketEventAcknowledgementRemoved = "post_acknowledgement_removed" WebsocketEventAcknowledgementRemoved = "post_acknowledgement_removed"
) )

Просмотреть файл

@@ -450,6 +450,7 @@ func (ts *TelemetryService) trackConfig() {
"restrict_link_previews": isDefault(*cfg.ServiceSettings.RestrictLinkPreviews, ""), "restrict_link_previews": isDefault(*cfg.ServiceSettings.RestrictLinkPreviews, ""),
"enable_custom_groups": *cfg.ServiceSettings.EnableCustomGroups, "enable_custom_groups": *cfg.ServiceSettings.EnableCustomGroups,
"post_priority": *cfg.ServiceSettings.PostPriority, "post_priority": *cfg.ServiceSettings.PostPriority,
"allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts,
}) })
ts.SendTelemetry(TrackConfigTeam, map[string]any{ ts.SendTelemetry(TrackConfigTeam, map[string]any{

Просмотреть файл

@@ -27,6 +27,7 @@ type OpenTracingLayer struct {
CommandStore store.CommandStore CommandStore store.CommandStore
CommandWebhookStore store.CommandWebhookStore CommandWebhookStore store.CommandWebhookStore
ComplianceStore store.ComplianceStore ComplianceStore store.ComplianceStore
DraftStore store.DraftStore
EmojiStore store.EmojiStore EmojiStore store.EmojiStore
FileInfoStore store.FileInfoStore FileInfoStore store.FileInfoStore
GroupStore store.GroupStore GroupStore store.GroupStore
@@ -93,6 +94,10 @@ func (s *OpenTracingLayer) Compliance() store.ComplianceStore {
return s.ComplianceStore return s.ComplianceStore
} }
func (s *OpenTracingLayer) Draft() store.DraftStore {
return s.DraftStore
}
func (s *OpenTracingLayer) Emoji() store.EmojiStore { func (s *OpenTracingLayer) Emoji() store.EmojiStore {
return s.EmojiStore return s.EmojiStore
} }
@@ -261,6 +266,11 @@ type OpenTracingLayerComplianceStore struct {
Root *OpenTracingLayer Root *OpenTracingLayer
} }
type OpenTracingLayerDraftStore struct {
store.DraftStore
Root *OpenTracingLayer
}
type OpenTracingLayerEmojiStore struct { type OpenTracingLayerEmojiStore struct {
store.EmojiStore store.EmojiStore
Root *OpenTracingLayer Root *OpenTracingLayer
@@ -3228,6 +3238,96 @@ func (s *OpenTracingLayerComplianceStore) Update(compliance *model.Compliance) (
return result, err 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 { func (s *OpenTracingLayerEmojiStore) Delete(emoji *model.Emoji, timestamp int64) error {
origCtx := s.Root.Store.Context() origCtx := s.Root.Store.Context()
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "EmojiStore.Delete") 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.CommandStore = &OpenTracingLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore}
newStore.CommandWebhookStore = &OpenTracingLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.CommandWebhookStore = &OpenTracingLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore}
newStore.ComplianceStore = &OpenTracingLayerComplianceStore{ComplianceStore: childStore.Compliance(), 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.EmojiStore = &OpenTracingLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore}
newStore.FileInfoStore = &OpenTracingLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} newStore.FileInfoStore = &OpenTracingLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore}
newStore.GroupStore = &OpenTracingLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore} newStore.GroupStore = &OpenTracingLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore}

Просмотреть файл

@@ -30,6 +30,7 @@ type RetryLayer struct {
CommandStore store.CommandStore CommandStore store.CommandStore
CommandWebhookStore store.CommandWebhookStore CommandWebhookStore store.CommandWebhookStore
ComplianceStore store.ComplianceStore ComplianceStore store.ComplianceStore
DraftStore store.DraftStore
EmojiStore store.EmojiStore EmojiStore store.EmojiStore
FileInfoStore store.FileInfoStore FileInfoStore store.FileInfoStore
GroupStore store.GroupStore GroupStore store.GroupStore
@@ -96,6 +97,10 @@ func (s *RetryLayer) Compliance() store.ComplianceStore {
return s.ComplianceStore return s.ComplianceStore
} }
func (s *RetryLayer) Draft() store.DraftStore {
return s.DraftStore
}
func (s *RetryLayer) Emoji() store.EmojiStore { func (s *RetryLayer) Emoji() store.EmojiStore {
return s.EmojiStore return s.EmojiStore
} }
@@ -264,6 +269,11 @@ type RetryLayerComplianceStore struct {
Root *RetryLayer Root *RetryLayer
} }
type RetryLayerDraftStore struct {
store.DraftStore
Root *RetryLayer
}
type RetryLayerEmojiStore struct { type RetryLayerEmojiStore struct {
store.EmojiStore store.EmojiStore
Root *RetryLayer 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 { func (s *RetryLayerEmojiStore) Delete(emoji *model.Emoji, timestamp int64) error {
tries := 0 tries := 0
@@ -14460,6 +14575,7 @@ func New(childStore store.Store) *RetryLayer {
newStore.CommandStore = &RetryLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} newStore.CommandStore = &RetryLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore}
newStore.CommandWebhookStore = &RetryLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.CommandWebhookStore = &RetryLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore}
newStore.ComplianceStore = &RetryLayerComplianceStore{ComplianceStore: childStore.Compliance(), 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.EmojiStore = &RetryLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore}
newStore.FileInfoStore = &RetryLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} newStore.FileInfoStore = &RetryLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore}
newStore.GroupStore = &RetryLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore} newStore.GroupStore = &RetryLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore}

Просмотреть файл

@@ -54,6 +54,7 @@ func genStore() *mocks.Store {
mock.On("UserTermsOfService").Return(&mocks.UserTermsOfServiceStore{}) mock.On("UserTermsOfService").Return(&mocks.UserTermsOfServiceStore{})
mock.On("Webhook").Return(&mocks.WebhookStore{}) mock.On("Webhook").Return(&mocks.WebhookStore{})
mock.On("NotifyAdmin").Return(&mocks.NotifyAdminStore{}) mock.On("NotifyAdmin").Return(&mocks.NotifyAdminStore{})
mock.On("Draft").Return(&mocks.DraftStore{})
mock.On("PostPriority").Return(&mocks.PostPriorityStore{}) mock.On("PostPriority").Return(&mocks.PostPriorityStore{})
mock.On("PostAcknowledgement").Return(&mocks.PostAcknowledgementStore{}) mock.On("PostAcknowledgement").Return(&mocks.PostAcknowledgementStore{})
return mock return mock

240
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
}

350
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)
})
})
}

Просмотреть файл

@@ -108,6 +108,7 @@ type SqlStoreStores struct {
UserTermsOfService store.UserTermsOfServiceStore UserTermsOfService store.UserTermsOfServiceStore
linkMetadata store.LinkMetadataStore linkMetadata store.LinkMetadataStore
sharedchannel store.SharedChannelStore sharedchannel store.SharedChannelStore
draft store.DraftStore
notifyAdmin store.NotifyAdminStore notifyAdmin store.NotifyAdminStore
postPriority store.PostPriorityStore postPriority store.PostPriorityStore
postAcknowledgement store.PostAcknowledgementStore postAcknowledgement store.PostAcknowledgementStore
@@ -215,6 +216,7 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS
store.stores.scheme = newSqlSchemeStore(store) store.stores.scheme = newSqlSchemeStore(store)
store.stores.group = newSqlGroupStore(store) store.stores.group = newSqlGroupStore(store)
store.stores.productNotices = newSqlProductNoticesStore(store) store.stores.productNotices = newSqlProductNoticesStore(store)
store.stores.draft = newSqlDraftStore(store, metrics)
store.stores.notifyAdmin = newSqlNotifyAdminStore(store) store.stores.notifyAdmin = newSqlNotifyAdminStore(store)
store.stores.postPriority = newSqlPostPriorityStore(store) store.stores.postPriority = newSqlPostPriorityStore(store)
store.stores.postAcknowledgement = newSqlPostAcknowledgementStore(store) store.stores.postAcknowledgement = newSqlPostAcknowledgementStore(store)
@@ -963,6 +965,10 @@ func (ss *SqlStore) PostPriority() store.PostPriorityStore {
return ss.stores.postPriority return ss.stores.postPriority
} }
func (ss *SqlStore) Draft() store.DraftStore {
return ss.stores.draft
}
func (ss *SqlStore) PostAcknowledgement() store.PostAcknowledgementStore { func (ss *SqlStore) PostAcknowledgement() store.PostAcknowledgementStore {
return ss.stores.postAcknowledgement return ss.stores.postAcknowledgement
} }

Просмотреть файл

@@ -59,6 +59,7 @@ type Store interface {
UserTermsOfService() UserTermsOfServiceStore UserTermsOfService() UserTermsOfServiceStore
LinkMetadata() LinkMetadataStore LinkMetadata() LinkMetadataStore
SharedChannel() SharedChannelStore SharedChannel() SharedChannelStore
Draft() DraftStore
MarkSystemRanUnitTests() MarkSystemRanUnitTests()
Close() Close()
LockToMaster() LockToMaster()
@@ -979,6 +980,14 @@ type PostPriorityStore interface {
GetForPosts(ids []string) ([]*model.PostPriority, error) 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 { type PostAcknowledgementStore interface {
Get(postID, userID string) (*model.PostAcknowledgement, error) Get(postID, userID string) (*model.PostAcknowledgement, error)
GetForPost(postID string) ([]*model.PostAcknowledgement, error) GetForPost(postID string) ([]*model.PostAcknowledgement, error)

13
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) {
}

121
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
}

Просмотреть файл

@@ -187,6 +187,22 @@ func (_m *Store) Context() context.Context {
return r0 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: // DropAllTables provides a mock function with given fields:
func (_m *Store) DropAllTables() { func (_m *Store) DropAllTables() {
_m.Called() _m.Called()

Просмотреть файл

@@ -54,6 +54,7 @@ type Store struct {
LinkMetadataStore mocks.LinkMetadataStore LinkMetadataStore mocks.LinkMetadataStore
SharedChannelStore mocks.SharedChannelStore SharedChannelStore mocks.SharedChannelStore
ProductNoticesStore mocks.ProductNoticesStore ProductNoticesStore mocks.ProductNoticesStore
DraftStore mocks.DraftStore
context context.Context context context.Context
NotifyAdminStore mocks.NotifyAdminStore NotifyAdminStore mocks.NotifyAdminStore
PostPriorityStore mocks.PostPriorityStore 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) Scheme() store.SchemeStore { return &s.SchemeStore }
func (s *Store) TermsOfService() store.TermsOfServiceStore { return &s.TermsOfServiceStore } func (s *Store) TermsOfService() store.TermsOfServiceStore { return &s.TermsOfServiceStore }
func (s *Store) UserTermsOfService() store.UserTermsOfServiceStore { return &s.UserTermsOfServiceStore } func (s *Store) UserTermsOfService() store.UserTermsOfServiceStore { return &s.UserTermsOfServiceStore }
func (s *Store) Draft() store.DraftStore { return &s.DraftStore }
func (s *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore { func (s *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore {
return &s.ChannelMemberHistoryStore return &s.ChannelMemberHistoryStore
} }
@@ -163,6 +165,7 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool {
&s.ThreadStore, &s.ThreadStore,
&s.ProductNoticesStore, &s.ProductNoticesStore,
&s.SharedChannelStore, &s.SharedChannelStore,
&s.DraftStore,
&s.NotifyAdminStore, &s.NotifyAdminStore,
&s.PostPriorityStore, &s.PostPriorityStore,
&s.PostAcknowledgementStore, &s.PostAcknowledgementStore,

Просмотреть файл

@@ -26,6 +26,7 @@ type TimerLayer struct {
CommandStore store.CommandStore CommandStore store.CommandStore
CommandWebhookStore store.CommandWebhookStore CommandWebhookStore store.CommandWebhookStore
ComplianceStore store.ComplianceStore ComplianceStore store.ComplianceStore
DraftStore store.DraftStore
EmojiStore store.EmojiStore EmojiStore store.EmojiStore
FileInfoStore store.FileInfoStore FileInfoStore store.FileInfoStore
GroupStore store.GroupStore GroupStore store.GroupStore
@@ -92,6 +93,10 @@ func (s *TimerLayer) Compliance() store.ComplianceStore {
return s.ComplianceStore return s.ComplianceStore
} }
func (s *TimerLayer) Draft() store.DraftStore {
return s.DraftStore
}
func (s *TimerLayer) Emoji() store.EmojiStore { func (s *TimerLayer) Emoji() store.EmojiStore {
return s.EmojiStore return s.EmojiStore
} }
@@ -260,6 +265,11 @@ type TimerLayerComplianceStore struct {
Root *TimerLayer Root *TimerLayer
} }
type TimerLayerDraftStore struct {
store.DraftStore
Root *TimerLayer
}
type TimerLayerEmojiStore struct { type TimerLayerEmojiStore struct {
store.EmojiStore store.EmojiStore
Root *TimerLayer Root *TimerLayer
@@ -2955,6 +2965,86 @@ func (s *TimerLayerComplianceStore) Update(compliance *model.Compliance) (*model
return result, err 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 { func (s *TimerLayerEmojiStore) Delete(emoji *model.Emoji, timestamp int64) error {
start := time.Now() 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.CommandStore = &TimerLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore}
newStore.CommandWebhookStore = &TimerLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore} newStore.CommandWebhookStore = &TimerLayerCommandWebhookStore{CommandWebhookStore: childStore.CommandWebhook(), Root: &newStore}
newStore.ComplianceStore = &TimerLayerComplianceStore{ComplianceStore: childStore.Compliance(), 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.EmojiStore = &TimerLayerEmojiStore{EmojiStore: childStore.Emoji(), Root: &newStore}
newStore.FileInfoStore = &TimerLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore} newStore.FileInfoStore = &TimerLayerFileInfoStore{FileInfoStore: childStore.FileInfo(), Root: &newStore}
newStore.GroupStore = &TimerLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore} newStore.GroupStore = &TimerLayerGroupStore{GroupStore: childStore.Group(), Root: &newStore}