From 27db854089a6752b996fdcc0089621cc44624f5e Mon Sep 17 00:00:00 2001 From: Kyriakos Z <3829551+koox00@users.noreply.github.com> Date: Thu, 24 Nov 2022 02:41:23 +0200 Subject: [PATCH] MM-47750: Adds PostAcknowledgements table and apis (#21689) * MM-46410: adds urgency on mention counts We have introduced priority for posts in https://github.com/mattermost/mattermost-webapp/pull/10951. We do need to color the mention badges in the webapp with a prominent color when a mention is posted in an urgent message. A thread has urgent mentions if the root post is marked as urgent, and the replies contain mentions to the user viewing the thread. This PR adds two columns, urgentmentioncount, and isurgent, in channelmembers, and threads tables respectively. Furthermore when asking for team/thread mention counts, we also return urgent mention counts for the user. * Fixes method in tests * empty commit * Fixes method call * Fixes single thread response is_urgent * Fixes errors * Fixes mysql migration and adds graphql schema * Fixes tests * Refactors IsUrgent and Adds PostsPriority table Changes: - removes is_urgent from the threads table - adds a new table to hold posts priorities - refactors priority out of the props and into the new table * Fixes * Adds translation strings * Fixes migrations and tests * Fixes tests * empty * Adds Priority to Copy * empty * Fixes priority not saved when boards is enabled We are nilifying Metadata when post.ForPlugin(), which didn't save Priority for a post when Boards was enabled. This commit copies metadata again to the post, so metadata are reinstated. * Fixes tests * Adding store tests and fixes syntax error * Uses threads.ThreadTeamId * Fixes error * Adds UrgentMentionCount in graphql api test * Fetches post priority in batches * Addresses review comments * Restore only priority on create post * Fixes tests * Nits * Some refactoring * Fixes get thread options when post priority enabled * Adds missing translation * Use the constant instead of "urgent" string * Renames urgent constant * MM-47750: Adds PostAcknowledgements table and apis - Adds post acknowledgement api/app/store methods to be able to save and delete post acknowledgements by users. - Adds wesbsocket events for acknowledgement created/deleted - Returns post acknowledgements in the post's metadata * Empty * Fixes incorrect urgent count when marking a post as unread * Adds license * Fixes ACK api, and adds tests * Fixes vet * Fixes tests * Addresses review comments * Remove unnecessary lines * Adds config option and changes return of delete ack * Empty * Empty * Enable config by default * Fixes intl * Fixes test after setting config default true * Changes endpoints to PostForUser * Avoids replica lag * Fixes error in merge * Fixes RetryLayer tests due to merge * Empty * Empty * Empty Co-authored-by: Mattermod --- api4/post.go | 77 ++++++++ api4/post_test.go | 84 ++++++++ api4/user_test.go | 2 + app/app_iface.go | 4 + app/opentracing/opentracing_layer.go | 88 +++++++++ app/post_acknowledgements.go | 130 +++++++++++++ app/post_acknowledgements_test.go | 149 ++++++++++++++ app/post_metadata.go | 12 ++ config/client.go | 4 + db/migrations/migrations.list | 4 + ...0098_create_post_acknowledgements.down.sql | 1 + ...000098_create_post_acknowledgements.up.sql | 6 + ...0098_create_post_acknowledgements.down.sql | 1 + ...000098_create_post_acknowledgements.up.sql | 6 + i18n/en.json | 36 ++++ model/client4.go | 22 +++ model/config.go | 2 +- model/post_acknowledgement.go | 24 +++ model/post_metadata.go | 21 +- model/websocket_message.go | 2 + store/opentracinglayer/opentracinglayer.go | 101 ++++++++++ store/retrylayer/retrylayer.go | 116 +++++++++++ store/retrylayer/retrylayer_test.go | 1 + store/sqlstore/post_acknowledgements_store.go | 144 ++++++++++++++ .../post_acknowledgements_store_test.go | 14 ++ store/sqlstore/store.go | 6 + store/store.go | 9 + .../mocks/PostAcknowledgementStore.go | 121 ++++++++++++ store/storetest/mocks/Store.go | 16 ++ .../storetest/post_acknowledgements_store.go | 181 ++++++++++++++++++ store/storetest/store.go | 27 +-- store/timerlayer/timerlayer.go | 91 +++++++++ 32 files changed, 1483 insertions(+), 19 deletions(-) create mode 100644 app/post_acknowledgements.go create mode 100644 app/post_acknowledgements_test.go create mode 100644 db/migrations/mysql/000098_create_post_acknowledgements.down.sql create mode 100644 db/migrations/mysql/000098_create_post_acknowledgements.up.sql create mode 100644 db/migrations/postgres/000098_create_post_acknowledgements.down.sql create mode 100644 db/migrations/postgres/000098_create_post_acknowledgements.up.sql create mode 100644 model/post_acknowledgement.go create mode 100644 store/sqlstore/post_acknowledgements_store.go create mode 100644 store/sqlstore/post_acknowledgements_store_test.go create mode 100644 store/storetest/mocks/PostAcknowledgementStore.go create mode 100644 store/storetest/post_acknowledgements_store.go diff --git a/api4/post.go b/api4/post.go index b4ad80d653..6aa7049563 100644 --- a/api4/post.go +++ b/api4/post.go @@ -38,6 +38,9 @@ func (api *API) InitPost() { api.BaseRoutes.Post.Handle("/pin", api.APISessionRequired(pinPost)).Methods("POST") api.BaseRoutes.Post.Handle("/unpin", api.APISessionRequired(unpinPost)).Methods("POST") + + api.BaseRoutes.PostForUser.Handle("/ack", api.APISessionRequired(acknowledgePost)).Methods("POST") + api.BaseRoutes.PostForUser.Handle("/ack", api.APISessionRequired(unacknowledgePost)).Methods("DELETE") } func createPost(c *Context, w http.ResponseWriter, r *http.Request) { @@ -941,6 +944,80 @@ func unpinPost(c *Context, w http.ResponseWriter, _ *http.Request) { saveIsPinnedPost(c, w, false) } +func acknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) { + // license check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequirePostId().RequireUserId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + c.SetPermissionError(model.PermissionEditOtherUsers) + return + } + + if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } + + acknowledgement, appErr := c.App.SaveAcknowledgementForPost(c.AppContext, c.Params.PostId, c.Params.UserId) + if appErr != nil { + c.Err = appErr + return + } + + js, err := json.Marshal(acknowledgement) + if err != nil { + c.Err = model.NewAppError("acknowledgePost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.Write(js) +} + +func unacknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) { + // license check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequirePostId().RequireUserId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + c.SetPermissionError(model.PermissionEditOtherUsers) + return + } + + if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } + + _, err := c.App.GetSinglePost(c.Params.PostId, false) + if err != nil { + c.Err = err + return + } + + appErr := c.App.DeleteAcknowledgementForPost(c.AppContext, c.Params.PostId, c.Params.UserId) + if appErr != nil { + c.Err = appErr + return + } + + ReturnStatusOK(w) +} + func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) { c.RequirePostId() if c.Err != nil { diff --git a/api4/post_test.go b/api4/post_test.go index 86b064191e..3240347e1e 100644 --- a/api4/post_test.go +++ b/api4/post_test.go @@ -3342,3 +3342,87 @@ func TestPostReminder(t *testing.T) { require.Truef(t, caught, "User should have received %s event", model.WebsocketEventEphemeralMessage) } + +func TestAcknowledgePost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + client := th.Client + + post := th.BasicPost + ack, _, err := client.AcknowledgePost(post.Id, th.BasicUser.Id) + require.NoError(t, err) + + acks, appErr := th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, appErr) + require.Len(t, acks, 1) + require.Equal(t, acks[0], ack) + + _, resp, err := client.AcknowledgePost("junk", th.BasicUser.Id) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + _, resp, err = client.AcknowledgePost(GenerateTestId(), th.BasicUser.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + _, resp, err = client.AcknowledgePost(post.Id, "junk") + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + _, resp, err = client.AcknowledgePost(post.Id, th.BasicUser2.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + client.Logout() + _, resp, err = client.AcknowledgePost(post.Id, th.BasicUser.Id) + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) + + _, _, err = th.SystemAdminClient.AcknowledgePost(post.Id, th.SystemAdminUser.Id) + require.NoError(t, err) +} + +func TestUnacknowledgePost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + client := th.Client + + post := th.BasicPost + ack, _, err := client.AcknowledgePost(post.Id, th.BasicUser.Id) + require.NoError(t, err) + + acks, appErr := th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, appErr) + require.Len(t, acks, 1) + require.Equal(t, acks[0], ack) + + resp, err := client.UnacknowledgePost("junk", th.BasicUser.Id) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + resp, err = client.UnacknowledgePost(GenerateTestId(), th.BasicUser.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + resp, err = client.UnacknowledgePost(post.Id, "junk") + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + resp, err = client.UnacknowledgePost(post.Id, th.BasicUser2.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + _, err = client.UnacknowledgePost(post.Id, th.BasicUser.Id) + require.NoError(t, err) + + acks, appErr = th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, appErr) + require.Len(t, acks, 0) + + client.Logout() + resp, err = client.UnacknowledgePost(post.Id, th.BasicUser.Id) + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) +} diff --git a/api4/user_test.go b/api4/user_test.go index d269593fce..17195f7d6d 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -6569,6 +6569,7 @@ func TestSingleThreadGet(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ThreadAutoFollow = true + *cfg.ServiceSettings.PostPriority = false *cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn *cfg.ServiceSettings.PostPriority = true cfg.FeatureFlags.PostPriority = true @@ -6615,6 +6616,7 @@ func TestSingleThreadGet(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.PostPriority = true + cfg.FeatureFlags.PostPriority = true }) tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) diff --git a/app/app_iface.go b/app/app_iface.go index 093d97a035..352ece5283 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -509,6 +509,7 @@ type AppIface interface { DeactivateGuests(c *request.Context) *model.AppError DeactivateMfa(userID string) *model.AppError DeauthorizeOAuthAppForUser(userID, appID string) *model.AppError + DeleteAcknowledgementForPost(c *request.Context, postID, userID string) *model.AppError DeleteAllExpiredPluginKeys() *model.AppError DeleteAllKeysForPlugin(pluginID string) *model.AppError DeleteBrandImage() *model.AppError @@ -567,6 +568,8 @@ type AppIface interface { GenerateMfaSecret(userID string) (*model.MfaSecret, *model.AppError) GeneratePublicLink(siteURL string, info *model.FileInfo) string GenerateSupportPacket() []model.FileData + GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) + GetAcknowledgementsForPostList(postList *model.PostList) (map[string][]*model.PostAcknowledgement, *model.AppError) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) GetAllChannels(c request.CTX, page, perPage int, opts model.ChannelSearchOpts) (model.ChannelListWithTeamData, *model.AppError) GetAllChannelsCount(c request.CTX, opts model.ChannelSearchOpts) (int64, *model.AppError) @@ -984,6 +987,7 @@ type AppIface interface { SanitizeProfile(user *model.User, asAdmin bool) SanitizeTeam(session model.Session, team *model.Team) *model.Team SanitizeTeams(session model.Session, teams []*model.Team) []*model.Team + SaveAcknowledgementForPost(c *request.Context, postID, userID string) (*model.PostAcknowledgement, *model.AppError) SaveAdminNotification(userId string, notifyData *model.NotifyAdminToUpgradeRequest) *model.AppError SaveAdminNotifyData(data *model.NotifyAdminData) (*model.NotifyAdminData, *model.AppError) SaveBrandImage(imageData *multipart.FileHeader) *model.AppError diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index d58f160c4b..f4ac4fbb51 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -2838,6 +2838,28 @@ func (a *OpenTracingAppLayer) DefaultChannelNames(c request.CTX) []string { return resultVar0 } +func (a *OpenTracingAppLayer) DeleteAcknowledgementForPost(c *request.Context, postID string, userID string) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteAcknowledgementForPost") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.DeleteAcknowledgementForPost(c, postID, userID) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) DeleteAllExpiredPluginKeys() *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteAllExpiredPluginKeys") @@ -4411,6 +4433,50 @@ func (a *OpenTracingAppLayer) GenerateSupportPacket() []model.FileData { return resultVar0 } +func (a *OpenTracingAppLayer) GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetAcknowledgementsForPost") + + 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.GetAcknowledgementsForPost(postID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetAcknowledgementsForPostList(postList *model.PostList) (map[string][]*model.PostAcknowledgement, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetAcknowledgementsForPostList") + + 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.GetAcknowledgementsForPostList(postList) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetActivePluginManifests") @@ -14312,6 +14378,28 @@ func (a *OpenTracingAppLayer) SanitizeTeams(session model.Session, teams []*mode return resultVar0 } +func (a *OpenTracingAppLayer) SaveAcknowledgementForPost(c *request.Context, postID string, userID string) (*model.PostAcknowledgement, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveAcknowledgementForPost") + + 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.SaveAcknowledgementForPost(c, postID, userID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) SaveAdminNotification(userId string, notifyData *model.NotifyAdminToUpgradeRequest) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveAdminNotification") diff --git a/app/post_acknowledgements.go b/app/post_acknowledgements.go new file mode 100644 index 0000000000..a91f76d02b --- /dev/null +++ b/app/post_acknowledgements.go @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "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) SaveAcknowledgementForPost(c *request.Context, postID, userID string) (*model.PostAcknowledgement, *model.AppError) { + post, err := a.GetSinglePost(postID, false) + if err != nil { + return nil, err + } + + channel, err := a.GetChannel(c, post.ChannelId) + if err != nil { + return nil, err + } + + if channel.DeleteAt > 0 { + return nil, model.NewAppError("SaveAcknowledgementForPost", "api.acknowledgement.save.archived_channel.app_error", nil, "", http.StatusForbidden) + } + + acknowledgedAt := model.GetMillis() + acknowledgement, nErr := a.Srv().Store().PostAcknowledgement().Save(postID, userID, acknowledgedAt) + + if nErr != nil { + var appErr *model.AppError + switch { + case errors.As(nErr, &appErr): + return nil, appErr + default: + return nil, model.NewAppError("SaveAcknowledgementForPost", "app.acknowledgement.save.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + } + + a.Srv().Go(func() { + a.sendAcknowledgementEvent(model.WebsocketEventAcknowledgementAdded, acknowledgement, post) + }) + + return acknowledgement, nil +} + +func (a *App) DeleteAcknowledgementForPost(c *request.Context, postID, userID string) *model.AppError { + post, err := a.GetSinglePost(postID, false) + if err != nil { + return err + } + + channel, err := a.GetChannel(c, post.ChannelId) + if err != nil { + return err + } + + if channel.DeleteAt > 0 { + return model.NewAppError("DeleteAcknowledgementForPost", "api.acknowledgement.delete.archived_channel.app_error", nil, "", http.StatusForbidden) + } + + oldAck, nErr := a.Srv().Store().PostAcknowledgement().Get(postID, userID) + + if nErr != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(nErr, &nfErr): + return model.NewAppError("GetPostAcknowledgement", "app.acknowledgement.get.app_error", nil, "", http.StatusNotFound).Wrap(nErr) + default: + return model.NewAppError("GetPostAcknowledgement", "app.acknowledgement.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + } + + if model.GetMillis()-oldAck.AcknowledgedAt > 5*60*1000 { + return model.NewAppError("DeleteAcknowledgementForPost", "api.acknowledgement.delete.deadline.app_error", nil, "", http.StatusForbidden) + } + + nErr = a.Srv().Store().PostAcknowledgement().Delete(oldAck) + if nErr != nil { + return model.NewAppError("DeleteAcknowledgementForPost", "app.acknowledgement.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + + a.Srv().Go(func() { + a.sendAcknowledgementEvent(model.WebsocketEventAcknowledgementRemoved, oldAck, post) + }) + + return nil +} + +func (a *App) GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) { + acknowledgements, nErr := a.Srv().Store().PostAcknowledgement().GetForPost(postID) + if nErr != nil { + return nil, model.NewAppError("GetAcknowledgementsForPost", "app.acknowledgement.getforpost.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + } + + return acknowledgements, nil +} + +func (a *App) GetAcknowledgementsForPostList(postList *model.PostList) (map[string][]*model.PostAcknowledgement, *model.AppError) { + acknowledgements, err := a.Srv().Store().PostAcknowledgement().GetForPosts(postList.Order) + + if err != nil { + return nil, model.NewAppError("GetPostAcknowledgementsForPostList", "app.acknowledgement.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + acknowledgementsMap := make(map[string][]*model.PostAcknowledgement) + + for _, ack := range acknowledgements { + acknowledgementsMap[ack.PostId] = append(acknowledgementsMap[ack.PostId], ack) + } + + return acknowledgementsMap, nil +} + +func (a *App) sendAcknowledgementEvent(event string, acknowledgement *model.PostAcknowledgement, post *model.Post) { + // send out that a acknowledgement has been added/removed + message := model.NewWebSocketEvent(event, "", post.ChannelId, "", nil, "") + + acknowledgementJSON, err := json.Marshal(acknowledgement) + if err != nil { + a.Log().Warn("Failed to encode acknowledgement to JSON", mlog.Err(err)) + } + message.Add("acknowledgement", string(acknowledgementJSON)) + a.Publish(message) +} diff --git a/app/post_acknowledgements_test.go b/app/post_acknowledgements_test.go new file mode 100644 index 0000000000..f1306c29fd --- /dev/null +++ b/app/post_acknowledgements_test.go @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/stretchr/testify/require" +) + +func TestPostAcknowledgementsApp(t *testing.T) { + t.Run("SaveAcknowledgementForPost", func(t *testing.T) { testSaveAcknowledgementForPost(t) }) + t.Run("DeleteAcknowledgementForPost", func(t *testing.T) { testDeleteAcknowledgementForPost(t) }) + t.Run("GetAcknowledgementsForPostList", func(t *testing.T) { testGetAcknowledgementsForPostList(t) }) +} + +func testSaveAcknowledgementForPost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("save acknowledgment for post should save acknowledgement", func(t *testing.T) { + post, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "message", + }, "", true) + + require.Nil(t, err) + + acknowledgment, err := th.App.SaveAcknowledgementForPost(th.Context, post.Id, th.BasicUser.Id) + require.Nil(t, err) + + require.Greater(t, acknowledgment.AcknowledgedAt, int64(0)) + require.Equal(t, post.Id, acknowledgment.PostId) + require.Equal(t, th.BasicUser.Id, acknowledgment.UserId) + }) +} + +func testDeleteAcknowledgementForPost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + post, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + Message: "message", + }, "", true) + require.Nil(t, err) + + t.Run("delete acknowledgment for post should delete acknowledgement", func(t *testing.T) { + _, err = th.App.SaveAcknowledgementForPost(th.Context, post.Id, th.BasicUser.Id) + require.Nil(t, err) + + acknowledgments, err := th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, err) + require.Len(t, acknowledgments, 1) + require.Greater(t, acknowledgments[0].AcknowledgedAt, int64(0)) + + err = th.App.DeleteAcknowledgementForPost(th.Context, post.Id, th.BasicUser.Id) + require.Nil(t, err) + + acknowledgments, err = th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, err) + require.Empty(t, acknowledgments) + }) + + t.Run("delete acknowledgment for post after 5 min after acknowledged should not delete", func(t *testing.T) { + _, nErr := th.App.Srv().Store().PostAcknowledgement().Save(post.Id, th.BasicUser.Id, model.GetMillis()-int64(6*60*1000)) + require.NoError(t, nErr) + + acknowledgments, err := th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, err) + require.Len(t, acknowledgments, 1) + require.Greater(t, acknowledgments[0].AcknowledgedAt, int64(0)) + + err = th.App.DeleteAcknowledgementForPost(th.Context, post.Id, th.BasicUser.Id) + require.NotNil(t, err) + require.Equal(t, 403, err.StatusCode) + + acknowledgments, err = th.App.GetAcknowledgementsForPost(post.Id) + require.Nil(t, err) + require.Len(t, acknowledgments, 1) + require.Greater(t, acknowledgments[0].AcknowledgedAt, int64(0)) + }) +} + +func testGetAcknowledgementsForPostList(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + p1, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + Message: "message", + }, "", true) + require.Nil(t, err) + + p2, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + Message: "message", + }, "", true) + require.Nil(t, err) + + p3, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + CreateAt: model.GetMillis(), + Message: "message", + }, "", true) + require.Nil(t, err) + + t.Run("get acknowledgments for post list should return a map", func(t *testing.T) { + _, err = th.App.SaveAcknowledgementForPost(th.Context, p1.Id, th.BasicUser.Id) + require.Nil(t, err) + _, err = th.App.SaveAcknowledgementForPost(th.Context, p2.Id, th.BasicUser.Id) + require.Nil(t, err) + _, err = th.App.SaveAcknowledgementForPost(th.Context, p1.Id, th.BasicUser2.Id) + require.Nil(t, err) + + postList := model.NewPostList() + postList.AddPost(p1) + postList.AddOrder(p1.Id) + postList.AddPost(p2) + postList.AddOrder(p2.Id) + postList.AddPost(p3) + postList.AddOrder(p3.Id) + + acks1, err := th.App.GetAcknowledgementsForPost(p1.Id) + require.Nil(t, err) + acks2, err := th.App.GetAcknowledgementsForPost(p2.Id) + require.Nil(t, err) + + acknowledgementsMap, err := th.App.GetAcknowledgementsForPostList(postList) + require.Nil(t, err) + + expected := map[string][]*model.PostAcknowledgement{ + p1.Id: acks1, + p2.Id: acks2, + } + require.Equal(t, expected, acknowledgementsMap) + require.Len(t, acknowledgementsMap[p1.Id], 2) + require.Len(t, acknowledgementsMap[p2.Id], 1) + require.Nil(t, acknowledgementsMap[p3.Id]) + }) +} diff --git a/app/post_metadata.go b/app/post_metadata.go index 368d0803f0..347dfe8708 100644 --- a/app/post_metadata.go +++ b/app/post_metadata.go @@ -63,10 +63,15 @@ func (a *App) PreparePostListForClient(c request.CTX, originalList *model.PostLi if a.isPostPriorityEnabled() { priority, _ := a.GetPriorityForPostList(list) + acknowledgements, _ := a.GetAcknowledgementsForPostList(list) + for _, id := range list.Order { if _, ok := priority[id]; ok { list.Posts[id].Metadata.Priority = priority[id] } + if _, ok := acknowledgements[id]; ok { + list.Posts[id].Metadata.Acknowledgements = acknowledgements[id] + } } } @@ -139,6 +144,13 @@ func (a *App) PreparePostForClient(c request.CTX, originalPost *model.Post, isNe } else { post.Metadata.Priority = priority } + + // Post's acknowledgements if any + if acknowledgements, err := a.GetAcknowledgementsForPost(post.Id); err != nil { + mlog.Warn("Failed to get post acknowledgements for a post", mlog.String("post_id", post.Id), mlog.Err(err)) + } else { + post.Metadata.Acknowledgements = acknowledgements + } } return post diff --git a/config/client.go b/config/client.go index c6a8587865..c6e650578b 100644 --- a/config/client.go +++ b/config/client.go @@ -206,6 +206,10 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li if license.SkuShortName == model.LicenseShortSkuProfessional || license.SkuShortName == model.LicenseShortSkuEnterprise { props["EnableCustomGroups"] = strconv.FormatBool(*c.ServiceSettings.EnableCustomGroups) } + + if (license.SkuShortName == model.LicenseShortSkuProfessional || license.SkuShortName == model.LicenseShortSkuEnterprise) && c.FeatureFlags.PostPriority { + props["PostAcknowledgements"] = "true" + } } return props diff --git a/db/migrations/migrations.list b/db/migrations/migrations.list index 1acb91f075..ba9b1103ba 100644 --- a/db/migrations/migrations.list +++ b/db/migrations/migrations.list @@ -194,6 +194,8 @@ db/migrations/mysql/000096_threads_threadteamid.down.sql db/migrations/mysql/000096_threads_threadteamid.up.sql db/migrations/mysql/000097_create_posts_priority.down.sql db/migrations/mysql/000097_create_posts_priority.up.sql +db/migrations/mysql/000098_create_post_acknowledgements.down.sql +db/migrations/mysql/000098_create_post_acknowledgements.up.sql db/migrations/postgres/000001_create_teams.down.sql db/migrations/postgres/000001_create_teams.up.sql db/migrations/postgres/000002_create_team_members.down.sql @@ -388,3 +390,5 @@ db/migrations/postgres/000096_threads_threadteamid.down.sql db/migrations/postgres/000096_threads_threadteamid.up.sql db/migrations/postgres/000097_create_posts_priority.down.sql db/migrations/postgres/000097_create_posts_priority.up.sql +db/migrations/postgres/000098_create_post_acknowledgements.down.sql +db/migrations/postgres/000098_create_post_acknowledgements.up.sql diff --git a/db/migrations/mysql/000098_create_post_acknowledgements.down.sql b/db/migrations/mysql/000098_create_post_acknowledgements.down.sql new file mode 100644 index 0000000000..2360ca5a6e --- /dev/null +++ b/db/migrations/mysql/000098_create_post_acknowledgements.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS PostAcknowledgements; diff --git a/db/migrations/mysql/000098_create_post_acknowledgements.up.sql b/db/migrations/mysql/000098_create_post_acknowledgements.up.sql new file mode 100644 index 0000000000..9eb7567a85 --- /dev/null +++ b/db/migrations/mysql/000098_create_post_acknowledgements.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS PostAcknowledgements ( + PostId varchar(26) NOT NULL, + UserId varchar(26) NOT NULL, + AcknowledgedAt bigint(20) DEFAULT NULL, + PRIMARY KEY (PostId, UserId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/db/migrations/postgres/000098_create_post_acknowledgements.down.sql b/db/migrations/postgres/000098_create_post_acknowledgements.down.sql new file mode 100644 index 0000000000..dc5e96624d --- /dev/null +++ b/db/migrations/postgres/000098_create_post_acknowledgements.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS postacknowledgements; diff --git a/db/migrations/postgres/000098_create_post_acknowledgements.up.sql b/db/migrations/postgres/000098_create_post_acknowledgements.up.sql new file mode 100644 index 0000000000..8f35e7b685 --- /dev/null +++ b/db/migrations/postgres/000098_create_post_acknowledgements.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS postacknowledgements( + postid VARCHAR(26) NOT NULL, + userid VARCHAR(26) NOT NULL, + acknowledgedat bigint, + PRIMARY KEY (postid, userid) +); diff --git a/i18n/en.json b/i18n/en.json index 589c90973a..d7b4627505 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -59,6 +59,18 @@ "id": "September", "translation": "September" }, + { + "id": "api.acknowledgement.delete.archived_channel.app_error", + "translation": "You cannot remove an acknowledgment in an archived channel." + }, + { + "id": "api.acknowledgement.delete.deadline.app_error", + "translation": "You cannot delete an acknowledgment after 5min have passed." + }, + { + "id": "api.acknowledgement.save.archived_channel.app_error", + "translation": "You cannot acknowledgment in an archived channel." + }, { "id": "api.admin.add_certificate.array.app_error", "translation": "No file under 'certificate' in request." @@ -4459,6 +4471,22 @@ "id": "api.websocket_handler.server_busy.app_error", "translation": "Server is busy, non-critical services are temporarily unavailable." }, + { + "id": "app.acknowledgement.delete.app_error", + "translation": "Unable to delete acknowledgement." + }, + { + "id": "app.acknowledgement.get.app_error", + "translation": "Unable to get acknowledgement." + }, + { + "id": "app.acknowledgement.getforpost.get.app_error", + "translation": "Unable to get acknowledgement for post." + }, + { + "id": "app.acknowledgement.save.save.app_error", + "translation": "Unable to save acknowledgement for post." + }, { "id": "app.admin.saml.failure_decode_metadata_xml_from_idp.app_error", "translation": "Could not decode the XML metadata information received from the Identity Provider." @@ -7927,6 +7955,14 @@ "id": "model.access.is_valid.user_id.app_error", "translation": "Invalid user id." }, + { + "id": "model.acknowledgement.is_valid.post_id.app_error", + "translation": "Invalid post id." + }, + { + "id": "model.acknowledgement.is_valid.user_id.app_error", + "translation": "Invalid user id." + }, { "id": "model.authorize.is_valid.auth_code.app_error", "translation": "Invalid authorization code." diff --git a/model/client4.go b/model/client4.go index 47a1ce366b..b05895859e 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8448,6 +8448,28 @@ func (c *Client4) GetNewTeamMembersSince(teamID string, timeRange string, page i return newTeamMembersList, BuildResponse(r), nil } +func (c *Client4) AcknowledgePost(postId, userId string) (*PostAcknowledgement, *Response, error) { + r, err := c.DoAPIPost(c.userRoute(userId)+c.postRoute(postId)+"/ack", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var ack *PostAcknowledgement + if jsonErr := json.NewDecoder(r.Body).Decode(&ack); jsonErr != nil { + return nil, nil, NewAppError("AcknowledgePost", "api.unmarshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + } + return ack, BuildResponse(r), nil +} + +func (c *Client4) UnacknowledgePost(postId, userId string) (*Response, error) { + r, err := c.DoAPIDelete(c.userRoute(userId) + c.postRoute(postId) + "/ack") + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + return BuildResponse(r), nil +} + func (c *Client4) AddUserToGroupSyncables(userID string) (*Response, error) { r, err := c.DoAPIPost(c.ldapRoute()+"/users/"+userID+"/group_sync_memberships", "") if err != nil { diff --git a/model/config.go b/model/config.go index be7a88b61d..a538f7032e 100644 --- a/model/config.go +++ b/model/config.go @@ -845,7 +845,7 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { } if s.PostPriority == nil { - s.PostPriority = NewBool(false) + s.PostPriority = NewBool(true) } } diff --git a/model/post_acknowledgement.go b/model/post_acknowledgement.go new file mode 100644 index 0000000000..227a678e6b --- /dev/null +++ b/model/post_acknowledgement.go @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import "net/http" + +type PostAcknowledgement struct { + UserId string `json:"user_id"` + PostId string `json:"post_id"` + AcknowledgedAt int64 `json:"acknowledged_at"` +} + +func (o *PostAcknowledgement) IsValid() *AppError { + if !IsValidId(o.UserId) { + return NewAppError("PostAcknowledgement.IsValid", "model.acknowledgement.is_valid.user_id.app_error", nil, "user_id="+o.UserId, http.StatusBadRequest) + } + + if !IsValidId(o.PostId) { + return NewAppError("PostAcknowledgement.IsValid", "model.acknowledgement.is_valid.post_id.app_error", nil, "post_id="+o.PostId, http.StatusBadRequest) + } + + return nil +} diff --git a/model/post_metadata.go b/model/post_metadata.go index 3730d06f55..ee49eb4f48 100644 --- a/model/post_metadata.go +++ b/model/post_metadata.go @@ -23,8 +23,11 @@ type PostMetadata struct { // Reactions holds reactions made to the post. Reactions []*Reaction `json:"reactions,omitempty"` - // Reactions holds reactions made to the post. + // Priority holds info about priority settings for the post. Priority *PostPriority `json:"priority,omitempty"` + + // Acknowledgements holds acknowledgements made by users to the post + Acknowledgements []*PostAcknowledgement `json:"acknowledgements,omitempty"` } type PostImage struct { @@ -57,6 +60,9 @@ func (p *PostMetadata) Copy() *PostMetadata { reactionsCopy := make([]*Reaction, len(p.Reactions)) copy(reactionsCopy, p.Reactions) + acknowledgementsCopy := make([]*PostAcknowledgement, len(p.Acknowledgements)) + copy(acknowledgementsCopy, p.Acknowledgements) + var postPriorityCopy *PostPriority if p.Priority != nil { postPriorityCopy = &PostPriority{ @@ -69,11 +75,12 @@ func (p *PostMetadata) Copy() *PostMetadata { } return &PostMetadata{ - Embeds: embedsCopy, - Emojis: emojisCopy, - Files: filesCopy, - Images: imagesCopy, - Reactions: reactionsCopy, - Priority: postPriorityCopy, + Embeds: embedsCopy, + Emojis: emojisCopy, + Files: filesCopy, + Images: imagesCopy, + Reactions: reactionsCopy, + Priority: postPriorityCopy, + Acknowledgements: acknowledgementsCopy, } } diff --git a/model/websocket_message.go b/model/websocket_message.go index 9cd3892453..8cd2ad1961 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -76,6 +76,8 @@ const ( WebsocketEventThreadFollowChanged = "thread_follow_changed" WebsocketEventThreadReadChanged = "thread_read_changed" WebsocketFirstAdminVisitMarketplaceStatusReceived = "first_admin_visit_marketplace_status_received" + WebsocketEventAcknowledgementAdded = "post_acknowledgement_added" + WebsocketEventAcknowledgementRemoved = "post_acknowledgement_removed" ) type WebSocketMessage interface { diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 3ab5c4715d..38e136d493 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -37,6 +37,7 @@ type OpenTracingLayer struct { OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore + PostAcknowledgementStore store.PostAcknowledgementStore PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore @@ -132,6 +133,10 @@ func (s *OpenTracingLayer) Post() store.PostStore { return s.PostStore } +func (s *OpenTracingLayer) PostAcknowledgement() store.PostAcknowledgementStore { + return s.PostAcknowledgementStore +} + func (s *OpenTracingLayer) PostPriority() store.PostPriorityStore { return s.PostPriorityStore } @@ -306,6 +311,11 @@ type OpenTracingLayerPostStore struct { Root *OpenTracingLayer } +type OpenTracingLayerPostAcknowledgementStore struct { + store.PostAcknowledgementStore + Root *OpenTracingLayer +} + type OpenTracingLayerPostPriorityStore struct { store.PostPriorityStore Root *OpenTracingLayer @@ -6544,6 +6554,96 @@ func (s *OpenTracingLayerPostStore) Update(newPost *model.Post, oldPost *model.P return result, err } +func (s *OpenTracingLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.Delete") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.PostAcknowledgementStore.Delete(acknowledgement) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostAcknowledgementStore.Get(postID, userID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.GetForPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostAcknowledgementStore.GetForPost(postID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.GetForPosts") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostAcknowledgementStore.GetForPosts(postIds) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerPostAcknowledgementStore) Save(postID string, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostAcknowledgementStore.Save") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostAcknowledgementStore.Save(postID, userID, acknowledgedAt) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostPriorityStore.GetForPost") @@ -12591,6 +12691,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.OAuthStore = &OpenTracingLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &OpenTracingLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &OpenTracingLayerPostStore{PostStore: childStore.Post(), Root: &newStore} + newStore.PostAcknowledgementStore = &OpenTracingLayerPostAcknowledgementStore{PostAcknowledgementStore: childStore.PostAcknowledgement(), Root: &newStore} newStore.PostPriorityStore = &OpenTracingLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &OpenTracingLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &OpenTracingLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 8dc782fce7..1da922dec1 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -40,6 +40,7 @@ type RetryLayer struct { OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore + PostAcknowledgementStore store.PostAcknowledgementStore PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore @@ -135,6 +136,10 @@ func (s *RetryLayer) Post() store.PostStore { return s.PostStore } +func (s *RetryLayer) PostAcknowledgement() store.PostAcknowledgementStore { + return s.PostAcknowledgementStore +} + func (s *RetryLayer) PostPriority() store.PostPriorityStore { return s.PostPriorityStore } @@ -309,6 +314,11 @@ type RetryLayerPostStore struct { Root *RetryLayer } +type RetryLayerPostAcknowledgementStore struct { + store.PostAcknowledgementStore + Root *RetryLayer +} + type RetryLayerPostPriorityStore struct { store.PostPriorityStore Root *RetryLayer @@ -7420,6 +7430,111 @@ func (s *RetryLayerPostStore) Update(newPost *model.Post, oldPost *model.Post) ( } +func (s *RetryLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error { + + tries := 0 + for { + err := s.PostAcknowledgementStore.Delete(acknowledgement) + 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 *RetryLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) { + + tries := 0 + for { + result, err := s.PostAcknowledgementStore.Get(postID, userID) + 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 *RetryLayerPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + + tries := 0 + for { + result, err := s.PostAcknowledgementStore.GetForPost(postID) + 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 *RetryLayerPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + + tries := 0 + for { + result, err := s.PostAcknowledgementStore.GetForPosts(postIds) + 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 *RetryLayerPostAcknowledgementStore) Save(postID string, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + + tries := 0 + for { + result, err := s.PostAcknowledgementStore.Save(postID, userID, acknowledgedAt) + 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 *RetryLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { tries := 0 @@ -14355,6 +14470,7 @@ func New(childStore store.Store) *RetryLayer { newStore.OAuthStore = &RetryLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &RetryLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &RetryLayerPostStore{PostStore: childStore.Post(), Root: &newStore} + newStore.PostAcknowledgementStore = &RetryLayerPostAcknowledgementStore{PostAcknowledgementStore: childStore.PostAcknowledgement(), Root: &newStore} newStore.PostPriorityStore = &RetryLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &RetryLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &RetryLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore} diff --git a/store/retrylayer/retrylayer_test.go b/store/retrylayer/retrylayer_test.go index b45bc7c561..7efbfa899a 100644 --- a/store/retrylayer/retrylayer_test.go +++ b/store/retrylayer/retrylayer_test.go @@ -55,6 +55,7 @@ func genStore() *mocks.Store { mock.On("Webhook").Return(&mocks.WebhookStore{}) mock.On("NotifyAdmin").Return(&mocks.NotifyAdminStore{}) mock.On("PostPriority").Return(&mocks.PostPriorityStore{}) + mock.On("PostAcknowledgement").Return(&mocks.PostAcknowledgementStore{}) return mock } diff --git a/store/sqlstore/post_acknowledgements_store.go b/store/sqlstore/post_acknowledgements_store.go new file mode 100644 index 0000000000..d3de3addea --- /dev/null +++ b/store/sqlstore/post_acknowledgements_store.go @@ -0,0 +1,144 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" +) + +type SqlPostAcknowledgementStore struct { + *SqlStore +} + +func newSqlPostAcknowledgementStore(sqlStore *SqlStore) store.PostAcknowledgementStore { + return &SqlPostAcknowledgementStore{sqlStore} +} + +func (s *SqlPostAcknowledgementStore) Get(postID, userID string) (*model.PostAcknowledgement, error) { + query := s.getQueryBuilder(). + Select("PostId", "UserId", "AcknowledgedAt"). + From("PostAcknowledgements"). + Where(sq.And{ + sq.Eq{"PostId": postID}, + sq.Eq{"UserId": userID}, + sq.NotEq{"AcknowledgedAt": 0}, + }) + + var acknowledgement model.PostAcknowledgement + err := s.GetReplicaX().GetBuilder(&acknowledgement, query) + if err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("PostAcknowledgement", postID) + } + + return nil, err + } + + return &acknowledgement, nil +} + +func (s *SqlPostAcknowledgementStore) Save(postID, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + if acknowledgedAt == 0 { + acknowledgedAt = model.GetMillis() + } + + acknowledgement := &model.PostAcknowledgement{ + UserId: userID, + PostId: postID, + AcknowledgedAt: acknowledgedAt, + } + + if err := acknowledgement.IsValid(); err != nil { + return nil, err + } + + query := s.getQueryBuilder(). + Insert("PostAcknowledgements"). + Columns("PostId", "UserId", "AcknowledgedAt"). + Values(acknowledgement.PostId, acknowledgement.UserId, acknowledgement.AcknowledgedAt) + + if s.DriverName() == model.DatabaseDriverMysql { + query = query.SuffixExpr(sq.Expr("ON DUPLICATE KEY UPDATE AcknowledgedAt = ?", acknowledgement.AcknowledgedAt)) + } else { + query = query.SuffixExpr(sq.Expr("ON CONFLICT (postid, userid) DO UPDATE SET AcknowledgedAt = ?", acknowledgement.AcknowledgedAt)) + } + + _, err := s.GetMasterX().ExecBuilder(query) + if err != nil { + return nil, err + } + + return acknowledgement, nil +} + +func (s *SqlPostAcknowledgementStore) Delete(ack *model.PostAcknowledgement) error { + query := s.getQueryBuilder(). + Update("PostAcknowledgements"). + Set("AcknowledgedAt", 0). + Where(sq.And{ + sq.Eq{"PostId": ack.PostId}, + sq.Eq{"UserId": ack.UserId}, + }) + + _, err := s.GetMasterX().ExecBuilder(query) + if err != nil { + return err + } + + return nil +} + +func (s *SqlPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + var acknowledgements []*model.PostAcknowledgement + + query := s.getQueryBuilder(). + Select("PostId", "UserId", "AcknowledgedAt"). + From("PostAcknowledgements"). + Where(sq.And{ + sq.NotEq{"AcknowledgedAt": 0}, + sq.Eq{"PostId": postID}, + }) + + err := s.GetReplicaX().SelectBuilder(&acknowledgements, query) + if err != nil { + return nil, errors.Wrapf(err, "failed to get PostAcknowledgements for postID=%s", postID) + } + + return acknowledgements, nil +} + +func (s *SqlPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + var acknowledgements []*model.PostAcknowledgement + + perPage := 200 + for i := 0; i < len(postIds); i += perPage { + j := i + perPage + if len(postIds) < j { + j = len(postIds) + } + + query := s.getQueryBuilder(). + Select("PostId", "UserId", "AcknowledgedAt"). + From("PostAcknowledgements"). + Where(sq.And{ + sq.Eq{"PostId": postIds[i:j]}, + sq.NotEq{"AcknowledgedAt": 0}, + }) + + var acknowledgementsBatch []*model.PostAcknowledgement + err := s.GetReplicaX().SelectBuilder(&acknowledgementsBatch, query) + if err != nil { + return nil, errors.Wrapf(err, "failed to get PostAcknowledgements for post list") + } + + acknowledgements = append(acknowledgements, acknowledgementsBatch...) + } + + return acknowledgements, nil +} diff --git a/store/sqlstore/post_acknowledgements_store_test.go b/store/sqlstore/post_acknowledgements_store_test.go new file mode 100644 index 0000000000..7de92cfef4 --- /dev/null +++ b/store/sqlstore/post_acknowledgements_store_test.go @@ -0,0 +1,14 @@ +// 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/store/storetest" +) + +func TestPostAcknowledgementsStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestPostAcknowledgementsStore) +} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 0c5aad077f..6a0cdbc51a 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -110,6 +110,7 @@ type SqlStoreStores struct { sharedchannel store.SharedChannelStore notifyAdmin store.NotifyAdminStore postPriority store.PostPriorityStore + postAcknowledgement store.PostAcknowledgementStore } type SqlStore struct { @@ -216,6 +217,7 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS store.stores.productNotices = newSqlProductNoticesStore(store) store.stores.notifyAdmin = newSqlNotifyAdminStore(store) store.stores.postPriority = newSqlPostPriorityStore(store) + store.stores.postAcknowledgement = newSqlPostAcknowledgementStore(store) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -961,6 +963,10 @@ func (ss *SqlStore) PostPriority() store.PostPriorityStore { return ss.stores.postPriority } +func (ss *SqlStore) PostAcknowledgement() store.PostAcknowledgementStore { + return ss.stores.postAcknowledgement +} + func (ss *SqlStore) DropAllTables() { if ss.DriverName() == model.DatabaseDriverPostgres { ss.masterX.Exec(`DO diff --git a/store/store.go b/store/store.go index c99b1fa7c2..4b3f4a94ea 100644 --- a/store/store.go +++ b/store/store.go @@ -85,6 +85,7 @@ type Store interface { Context() context.Context NotifyAdmin() NotifyAdminStore PostPriority() PostPriorityStore + PostAcknowledgement() PostAcknowledgementStore } type RetentionPolicyStore interface { @@ -978,6 +979,14 @@ type PostPriorityStore interface { GetForPosts(ids []string) ([]*model.PostPriority, error) } +type PostAcknowledgementStore interface { + Get(postID, userID string) (*model.PostAcknowledgement, error) + GetForPost(postID string) ([]*model.PostAcknowledgement, error) + GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) + Save(postID, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) + Delete(acknowledgement *model.PostAcknowledgement) error +} + // ChannelSearchOpts contains options for searching channels. // // NotAssociatedToGroup will exclude channels that have associated, active GroupChannels records. diff --git a/store/storetest/mocks/PostAcknowledgementStore.go b/store/storetest/mocks/PostAcknowledgementStore.go new file mode 100644 index 0000000000..ad130137da --- /dev/null +++ b/store/storetest/mocks/PostAcknowledgementStore.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" +) + +// PostAcknowledgementStore is an autogenerated mock type for the PostAcknowledgementStore type +type PostAcknowledgementStore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: acknowledgement +func (_m *PostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error { + ret := _m.Called(acknowledgement) + + var r0 error + if rf, ok := ret.Get(0).(func(*model.PostAcknowledgement) error); ok { + r0 = rf(acknowledgement) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: postID, userID +func (_m *PostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) { + ret := _m.Called(postID, userID) + + var r0 *model.PostAcknowledgement + if rf, ok := ret.Get(0).(func(string, string) *model.PostAcknowledgement); ok { + r0 = rf(postID, userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PostAcknowledgement) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(postID, userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetForPost provides a mock function with given fields: postID +func (_m *PostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + ret := _m.Called(postID) + + var r0 []*model.PostAcknowledgement + if rf, ok := ret.Get(0).(func(string) []*model.PostAcknowledgement); ok { + r0 = rf(postID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PostAcknowledgement) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(postID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetForPosts provides a mock function with given fields: postIds +func (_m *PostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + ret := _m.Called(postIds) + + var r0 []*model.PostAcknowledgement + if rf, ok := ret.Get(0).(func([]string) []*model.PostAcknowledgement); ok { + r0 = rf(postIds) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PostAcknowledgement) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(postIds) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Save provides a mock function with given fields: postID, userID, acknowledgedAt +func (_m *PostAcknowledgementStore) Save(postID string, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + ret := _m.Called(postID, userID, acknowledgedAt) + + var r0 *model.PostAcknowledgement + if rf, ok := ret.Get(0).(func(string, string, int64) *model.PostAcknowledgement); ok { + r0 = rf(postID, userID, acknowledgedAt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.PostAcknowledgement) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, int64) error); ok { + r1 = rf(postID, userID, acknowledgedAt) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/store/storetest/mocks/Store.go b/store/storetest/mocks/Store.go index 1d8e8ac326..cfa20ca980 100644 --- a/store/storetest/mocks/Store.go +++ b/store/storetest/mocks/Store.go @@ -475,6 +475,22 @@ func (_m *Store) Post() store.PostStore { return r0 } +// PostAcknowledgement provides a mock function with given fields: +func (_m *Store) PostAcknowledgement() store.PostAcknowledgementStore { + ret := _m.Called() + + var r0 store.PostAcknowledgementStore + if rf, ok := ret.Get(0).(func() store.PostAcknowledgementStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.PostAcknowledgementStore) + } + } + + return r0 +} + // PostPriority provides a mock function with given fields: func (_m *Store) PostPriority() store.PostPriorityStore { ret := _m.Called() diff --git a/store/storetest/post_acknowledgements_store.go b/store/storetest/post_acknowledgements_store.go new file mode 100644 index 0000000000..ac751119bf --- /dev/null +++ b/store/storetest/post_acknowledgements_store.go @@ -0,0 +1,181 @@ +// 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/model" + "github.com/mattermost/mattermost-server/v6/store" + "github.com/stretchr/testify/require" +) + +func TestPostAcknowledgementsStore(t *testing.T, ss store.Store, s SqlStore) { + t.Run("Save", func(t *testing.T) { testPostAcknowledgementsStoreSave(t, ss) }) + t.Run("GetForPost", func(t *testing.T) { testPostAcknowledgementsStoreGetForPost(t, ss) }) + t.Run("GetForPosts", func(t *testing.T) { testPostAcknowledgementsStoreGetForPosts(t, ss) }) +} + +func testPostAcknowledgementsStoreSave(t *testing.T, ss store.Store) { + userId1 := model.NewId() + + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + _, err := ss.Post().Save(&p1) + require.NoError(t, err) + + t.Run("consecutive saves should just update the acknowledged at", func(t *testing.T) { + _, err := ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + + _, err = ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + + ack1, err := ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + + acknowledgements, err := ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack1}) + }) +} + +func testPostAcknowledgementsStoreGetForPost(t *testing.T, ss store.Store) { + userId1 := model.NewId() + userId2 := model.NewId() + userId3 := model.NewId() + + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + _, err := ss.Post().Save(&p1) + require.NoError(t, err) + + t.Run("get acknowledgements for post", func(t *testing.T) { + ack1, err := ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + ack2, err := ss.PostAcknowledgement().Save(p1.Id, userId2, 0) + require.NoError(t, err) + ack3, err := ss.PostAcknowledgement().Save(p1.Id, userId3, 0) + require.NoError(t, err) + + acknowledgements, err := ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack1, ack2, ack3}) + + err = ss.PostAcknowledgement().Delete(ack1) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack2, ack3}) + + err = ss.PostAcknowledgement().Delete(ack2) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack3}) + + err = ss.PostAcknowledgement().Delete(ack3) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPost(p1.Id) + require.NoError(t, err) + require.Empty(t, acknowledgements) + }) +} + +func testPostAcknowledgementsStoreGetForPosts(t *testing.T, ss store.Store) { + userId1 := model.NewId() + userId2 := model.NewId() + userId3 := model.NewId() + + p1 := model.Post{} + p1.ChannelId = model.NewId() + p1.UserId = model.NewId() + p1.Message = NewTestId() + p1.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString("important"), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + p2 := model.Post{} + p2.ChannelId = model.NewId() + p2.UserId = model.NewId() + p2.Message = NewTestId() + p2.Metadata = &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(""), + RequestedAck: model.NewBool(true), + PersistentNotifications: model.NewBool(false), + }, + } + _, errIdx, err := ss.Post().SaveMultiple([]*model.Post{&p1, &p2}) + require.NoError(t, err) + require.Equal(t, -1, errIdx) + + t.Run("get acknowledgements for post", func(t *testing.T) { + ack1, err := ss.PostAcknowledgement().Save(p1.Id, userId1, 0) + require.NoError(t, err) + ack2, err := ss.PostAcknowledgement().Save(p1.Id, userId2, 0) + require.NoError(t, err) + ack3, err := ss.PostAcknowledgement().Save(p2.Id, userId2, 0) + require.NoError(t, err) + ack4, err := ss.PostAcknowledgement().Save(p2.Id, userId3, 0) + require.NoError(t, err) + + acknowledgements, err := ss.PostAcknowledgement().GetForPosts([]string{p1.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack1, ack2}) + + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack3, ack4}) + + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack1, ack2, ack3, ack4}) + + err = ss.PostAcknowledgement().Delete(ack1) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack2, ack3, ack4}) + + err = ss.PostAcknowledgement().Delete(ack2) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack3, ack4}) + + err = ss.PostAcknowledgement().Delete(ack3) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.ElementsMatch(t, acknowledgements, []*model.PostAcknowledgement{ack4}) + + err = ss.PostAcknowledgement().Delete(ack4) + require.NoError(t, err) + acknowledgements, err = ss.PostAcknowledgement().GetForPosts([]string{p1.Id, p2.Id}) + require.NoError(t, err) + require.Empty(t, acknowledgements) + }) +} diff --git a/store/storetest/store.go b/store/storetest/store.go index 4f785c0426..93a85d3ab1 100644 --- a/store/storetest/store.go +++ b/store/storetest/store.go @@ -57,6 +57,7 @@ type Store struct { context context.Context NotifyAdminStore mocks.NotifyAdminStore PostPriorityStore mocks.PostPriorityStore + PostAcknowledgementStore mocks.PostAcknowledgementStore } func (s *Store) SetContext(context context.Context) { s.context = context } @@ -102,17 +103,20 @@ func (s *Store) Group() store.GroupStore { return &s.GroupStore func (s *Store) LinkMetadata() store.LinkMetadataStore { return &s.LinkMetadataStore } func (s *Store) SharedChannel() store.SharedChannelStore { return &s.SharedChannelStore } func (s *Store) PostPriority() store.PostPriorityStore { return &s.PostPriorityStore } -func (s *Store) MarkSystemRanUnitTests() { /* do nothing */ } -func (s *Store) Close() { /* do nothing */ } -func (s *Store) LockToMaster() { /* do nothing */ } -func (s *Store) UnlockFromMaster() { /* do nothing */ } -func (s *Store) DropAllTables() { /* do nothing */ } -func (s *Store) GetDbVersion(bool) (string, error) { return "", nil } -func (s *Store) GetInternalMasterDB() *sql.DB { return nil } -func (s *Store) GetInternalReplicaDB() *sql.DB { return nil } -func (s *Store) GetInternalReplicaDBs() []*sql.DB { return nil } -func (s *Store) RecycleDBConnections(time.Duration) {} -func (s *Store) GetDBSchemaVersion() (int, error) { return 1, nil } +func (s *Store) PostAcknowledgement() store.PostAcknowledgementStore { + return &s.PostAcknowledgementStore +} +func (s *Store) MarkSystemRanUnitTests() { /* do nothing */ } +func (s *Store) Close() { /* do nothing */ } +func (s *Store) LockToMaster() { /* do nothing */ } +func (s *Store) UnlockFromMaster() { /* do nothing */ } +func (s *Store) DropAllTables() { /* do nothing */ } +func (s *Store) GetDbVersion(bool) (string, error) { return "", nil } +func (s *Store) GetInternalMasterDB() *sql.DB { return nil } +func (s *Store) GetInternalReplicaDB() *sql.DB { return nil } +func (s *Store) GetInternalReplicaDBs() []*sql.DB { return nil } +func (s *Store) RecycleDBConnections(time.Duration) {} +func (s *Store) GetDBSchemaVersion() (int, error) { return 1, nil } func (s *Store) GetAppliedMigrations() ([]model.AppliedMigration, error) { return []model.AppliedMigration{}, nil } @@ -161,5 +165,6 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.SharedChannelStore, &s.NotifyAdminStore, &s.PostPriorityStore, + &s.PostAcknowledgementStore, ) } diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index ba5d2436e7..3395ab8b45 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -36,6 +36,7 @@ type TimerLayer struct { OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore + PostAcknowledgementStore store.PostAcknowledgementStore PostPriorityStore store.PostPriorityStore PreferenceStore store.PreferenceStore ProductNoticesStore store.ProductNoticesStore @@ -131,6 +132,10 @@ func (s *TimerLayer) Post() store.PostStore { return s.PostStore } +func (s *TimerLayer) PostAcknowledgement() store.PostAcknowledgementStore { + return s.PostAcknowledgementStore +} + func (s *TimerLayer) PostPriority() store.PostPriorityStore { return s.PostPriorityStore } @@ -305,6 +310,11 @@ type TimerLayerPostStore struct { Root *TimerLayer } +type TimerLayerPostAcknowledgementStore struct { + store.PostAcknowledgementStore + Root *TimerLayer +} + type TimerLayerPostPriorityStore struct { store.PostPriorityStore Root *TimerLayer @@ -5917,6 +5927,86 @@ func (s *TimerLayerPostStore) Update(newPost *model.Post, oldPost *model.Post) ( return result, err } +func (s *TimerLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledgement) error { + start := time.Now() + + err := s.PostAcknowledgementStore.Delete(acknowledgement) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) { + start := time.Now() + + result, err := s.PostAcknowledgementStore.Get(postID, userID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) { + start := time.Now() + + result, err := s.PostAcknowledgementStore.GetForPost(postID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.GetForPost", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostAcknowledgementStore) GetForPosts(postIds []string) ([]*model.PostAcknowledgement, error) { + start := time.Now() + + result, err := s.PostAcknowledgementStore.GetForPosts(postIds) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.GetForPosts", success, elapsed) + } + return result, err +} + +func (s *TimerLayerPostAcknowledgementStore) Save(postID string, userID string, acknowledgedAt int64) (*model.PostAcknowledgement, error) { + start := time.Now() + + result, err := s.PostAcknowledgementStore.Save(postID, userID, acknowledgedAt) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.Save", success, elapsed) + } + return result, err +} + func (s *TimerLayerPostPriorityStore) GetForPost(postId string) (*model.PostPriority, error) { start := time.Now() @@ -11344,6 +11434,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.OAuthStore = &TimerLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &TimerLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &TimerLayerPostStore{PostStore: childStore.Post(), Root: &newStore} + newStore.PostAcknowledgementStore = &TimerLayerPostAcknowledgementStore{PostAcknowledgementStore: childStore.PostAcknowledgement(), Root: &newStore} newStore.PostPriorityStore = &TimerLayerPostPriorityStore{PostPriorityStore: childStore.PostPriority(), Root: &newStore} newStore.PreferenceStore = &TimerLayerPreferenceStore{PreferenceStore: childStore.Preference(), Root: &newStore} newStore.ProductNoticesStore = &TimerLayerProductNoticesStore{ProductNoticesStore: childStore.ProductNotices(), Root: &newStore}