diff --git a/Makefile b/Makefile index d8ea236799..9fc1c738bf 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build package run stop run-client run-server run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race migrations-bindata new-migration migrations-extract +.PHONY: build package run stop run-client run-server run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race new-migration migrations-extract ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) @@ -357,8 +357,6 @@ new-migration: ## Creates a new migration. Run with make new-migration name=<> @echo "Generating new migration for postgres" $(GOBIN)/morph generate $(name) --driver postgres --dir db/migrations --sequence - @echo "When you are done writing your migration, run 'make migrations-bindata'" - filestore-mocks: ## Creates mock files. $(GO) install github.com/vektra/mockery/v2/...@v2.10.4 $(GOBIN)/mockery --dir shared/filestore --all --output shared/filestore/mocks --note 'Regenerate this file using `make filestore-mocks`.' diff --git a/api4/cloud.go b/api4/cloud.go index 9e10aafc08..e71659537f 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -50,25 +50,6 @@ func (api *API) InitCloud() { // POST /api/v4/cloud/webhook api.BaseRoutes.Cloud.Handle("/webhook", api.CloudAPIKeyRequired(handleCWSWebhook)).Methods("POST") - - api.BaseRoutes.Cloud.Handle("/notify-admin-to-upgrade", api.APISessionRequired(handleNotifyAdminToUpgrade)).Methods("POST") -} - -func handleNotifyAdminToUpgrade(c *Context, w http.ResponseWriter, r *http.Request) { - var notifyAdminRequest *model.NotifyAdminToUpgradeRequest - err := json.NewDecoder(r.Body).Decode(¬ifyAdminRequest) - if err != nil { - c.SetInvalidParamWithErr("notifyAdminRequest", err) - return - } - - appErr := c.App.NotifySystemAdminsToUpgrade(c.AppContext, notifyAdminRequest.CurrentTeamId) - if appErr != nil { - c.Err = appErr - return - } - - ReturnStatusOK(w) } func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) { diff --git a/api4/cloud_test.go b/api4/cloud_test.go index 1964c6981d..45c0eaa5ae 100644 --- a/api4/cloud_test.go +++ b/api4/cloud_test.go @@ -5,11 +5,8 @@ package api4 import ( "errors" - "fmt" "net/http" - "os" "testing" - "time" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -296,109 +293,6 @@ func Test_requestTrial(t *testing.T) { require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") }) } - -func TestNotifyAdminToUpgrade(t *testing.T) { - t.Run("user can only notify admin once in cool off period", func(t *testing.T) { - th := Setup(t).InitBasic().InitLogin() - defer th.TearDown() - - statusCode := th.Client.NotifyAdmin(&model.NotifyAdminToUpgradeRequest{ - CurrentTeamId: th.BasicTeam.Id, - }) - - bot, appErr := th.App.GetSystemBot() - require.Nil(t, appErr) - - // message sending is async, wait time for it - var channel *model.Channel - var err error - var timeout = 5 * time.Second - begin := time.Now() - for { - if time.Since(begin) > timeout { - break - } - channel, err = th.App.Srv().Store.Channel().GetByName("", model.GetDMNameFromIds(bot.UserId, th.SystemAdminUser.Id), false) - if err == nil && channel != nil { - break - } - time.Sleep(100 * time.Millisecond) - } - require.NoError(t, err, "Expected message to have been sent within %d seconds", timeout) - - postList, err := th.App.Srv().Store.Post().GetPosts(model.GetPostsOptions{ChannelId: channel.Id, Page: 0, PerPage: 1}, false, map[string]bool{}) - require.NoError(t, err) - - require.Equal(t, len(postList.Order), 1) - - post := postList.Posts[postList.Order[0]] - - require.Equal(t, fmt.Sprintf("%sup_notification", model.PostCustomTypePrefix), post.Type) - require.Equal(t, bot.UserId, post.UserId) - require.Equal(t, fmt.Sprintf("A member of %s has notified you to upgrade this workspace.", th.BasicTeam.Name), post.Message) - - require.Equal(t, http.StatusOK, statusCode) - - // second time trying to call notify endpoint by same user is forbidden - statusCode = th.Client.NotifyAdmin(&model.NotifyAdminToUpgradeRequest{ - CurrentTeamId: th.BasicTeam.Id, - }) - require.Equal(t, http.StatusForbidden, statusCode) - }) - - t.Run("user can only notify admin after cool off period", func(t *testing.T) { - th := Setup(t).InitBasic().InitLogin() - defer th.TearDown() - - os.Setenv("MM_CLOUD_NOTIFY_ADMIN_COOL_OFF_DAYS", "0.00003472222222") // set to 3 seconds - defer os.Unsetenv("MM_CLOUD_NOTIFY_ADMIN_COOL_OFF_DAYS") - - statusCode := th.Client.NotifyAdmin(&model.NotifyAdminToUpgradeRequest{ - CurrentTeamId: th.BasicTeam.Id, - }) - - bot, appErr := th.App.GetSystemBot() - require.Nil(t, appErr) - - channel, err := th.App.Srv().Store.Channel().GetByName("", model.GetDMNameFromIds(bot.UserId, th.SystemAdminUser.Id), false) - require.NoError(t, err) - - postList, err := th.App.Srv().Store.Post().GetPosts(model.GetPostsOptions{ChannelId: channel.Id, Page: 0, PerPage: 1}, false, map[string]bool{}) - require.NoError(t, err) - - require.Equal(t, len(postList.Order), 1) - - post := postList.Posts[postList.Order[0]] - - require.Equal(t, fmt.Sprintf("%sup_notification", model.PostCustomTypePrefix), post.Type) - require.Equal(t, bot.UserId, post.UserId) - require.Equal(t, fmt.Sprintf("A member of %s has notified you to upgrade this workspace.", th.BasicTeam.Name), post.Message) - - require.Equal(t, http.StatusOK, statusCode) - - time.Sleep(5 * time.Second) - - // second time trying to call notify endpoint by same user is NOT forbidden because it is after cool off period set to 3 seconds - statusCode = th.Client.NotifyAdmin(&model.NotifyAdminToUpgradeRequest{ - CurrentTeamId: th.BasicTeam.Id, - }) - - require.Equal(t, http.StatusOK, statusCode) - }) - - t.Run("can cloud/model.Notify", func(t *testing.T) { - - os.Setenv("MM_CLOUD_NOTIFY_ADMIN_COOL_OFF_DAYS", "10") // set to 10 days - canNotify := model.CanNotify(model.GetMillis()) - require.Equal(t, false, canNotify) - - os.Setenv("MM_CLOUD_NOTIFY_ADMIN_COOL_OFF_DAYS", "0.00003472222222") // set to 3 seconds - canNotify = model.CanNotify(model.GetMillis()) - time.Sleep(5 * time.Second) - require.Equal(t, false, canNotify) - os.Unsetenv("MM_CLOUD_NOTIFY_ADMIN_COOL_OFF_DAYS") - }) -} func Test_validateBusinessEmail(t *testing.T) { t.Run("Returns forbidden for non admin executors", func(t *testing.T) { th := Setup(t).InitBasic() diff --git a/api4/notify_admin.go b/api4/notify_admin.go new file mode 100644 index 0000000000..36af8263a0 --- /dev/null +++ b/api4/notify_admin.go @@ -0,0 +1,57 @@ +// 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" +) + +func handleNotifyAdmin(c *Context, w http.ResponseWriter, r *http.Request) { + var notifyAdminRequest *model.NotifyAdminToUpgradeRequest + err := json.NewDecoder(r.Body).Decode(¬ifyAdminRequest) + if err != nil { + c.SetInvalidParamWithErr("notifyAdminRequest", err) + return + } + + userId := c.AppContext.Session().UserId + appErr := c.App.SaveAdminNotification(userId, notifyAdminRequest) + if appErr != nil { + c.Err = appErr + return + } + + ReturnStatusOK(w) +} + +func handleTriggerNotifyAdminPosts(c *Context, w http.ResponseWriter, r *http.Request) { + if !*c.App.Config().ServiceSettings.EnableAPITriggerAdminNotifications { + c.Err = model.NewAppError("Api4.handleTriggerNotifyAdminPosts", "api.cloud.app_error", nil, "Manual triggering of notifications not allowed", http.StatusForbidden) + return + } + + var notifyAdminRequest *model.NotifyAdminToUpgradeRequest + err := json.NewDecoder(r.Body).Decode(¬ifyAdminRequest) + if err != nil { + c.SetInvalidParamWithErr("notifyAdminRequest", err) + return + } + + // only system admins can manually trigger these notifications + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + appErr := c.App.SendNotifyAdminPosts(c.AppContext, "", "", notifyAdminRequest.TrialNotification) + if appErr != nil { + c.Err = appErr + return + } + + ReturnStatusOK(w) +} diff --git a/api4/notify_admin_test.go b/api4/notify_admin_test.go new file mode 100644 index 0000000000..2fb6f9e9cd --- /dev/null +++ b/api4/notify_admin_test.go @@ -0,0 +1,155 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +package api4 + +import ( + "net/http" + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/stretchr/testify/require" +) + +func TestNotifyAdmin(t *testing.T) { + t.Run("error when plan is unknown when notifying on upgrade", func(t *testing.T) { + th := Setup(t).InitBasic().InitLogin() + defer th.TearDown() + + statusCode, err := th.Client.NotifyAdmin(&model.NotifyAdminToUpgradeRequest{ + RequiredPlan: "Unknown plan", + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + }) + + require.Error(t, err) + require.Equal(t, err.Error(), ": Unable to save notify data.") + require.Equal(t, http.StatusInternalServerError, statusCode) + + }) + + t.Run("error when plan is unknown when notifying to trial", func(t *testing.T) { + th := Setup(t).InitBasic().InitLogin() + defer th.TearDown() + + statusCode, err := th.Client.NotifyAdmin(&model.NotifyAdminToUpgradeRequest{ + RequiredPlan: "Unknown plan", + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + TrialNotification: true, + }) + + require.Error(t, err) + require.Equal(t, err.Error(), ": Unable to save notify data.") + require.Equal(t, http.StatusInternalServerError, statusCode) + + }) + + t.Run("error when feature is unknown when notifying on upgrade", func(t *testing.T) { + th := Setup(t).InitBasic().InitLogin() + defer th.TearDown() + + statusCode, err := th.Client.NotifyAdmin(&model.NotifyAdminToUpgradeRequest{ + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: "Unknown feature", + }) + + require.Error(t, err) + require.Equal(t, err.Error(), ": Unable to save notify data.") + require.Equal(t, http.StatusInternalServerError, statusCode) + }) + + t.Run("error when feature is unknown when notifying to trial", func(t *testing.T) { + th := Setup(t).InitBasic().InitLogin() + defer th.TearDown() + + statusCode, err := th.Client.NotifyAdmin(&model.NotifyAdminToUpgradeRequest{ + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: "Unknown feature", + TrialNotification: true, + }) + + require.Error(t, err) + require.Equal(t, err.Error(), ": Unable to save notify data.") + require.Equal(t, http.StatusInternalServerError, statusCode) + }) + + t.Run("error when user tries to notify again on same feature within the cool off period", func(t *testing.T) { + th := Setup(t).InitBasic().InitLogin() + defer th.TearDown() + + statusCode, err := th.Client.NotifyAdmin(&model.NotifyAdminToUpgradeRequest{ + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, statusCode) + + // second attempt to notify for all professional features + statusCode, err = th.Client.NotifyAdmin(&model.NotifyAdminToUpgradeRequest{ + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + }) + require.Error(t, err) + + require.Equal(t, err.Error(), ": Already notified admin") + require.Equal(t, http.StatusForbidden, statusCode) + }) + + t.Run("successfully save upgrade notification", func(t *testing.T) { + th := Setup(t).InitBasic().InitLogin() + defer th.TearDown() + + statusCode, err := th.Client.NotifyAdmin(&model.NotifyAdminToUpgradeRequest{ + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + }) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, statusCode) + }) +} + +func TestTriggerNotifyAdmin(t *testing.T) { + t.Run("error when EnableAPITriggerAdminNotifications is not true", func(t *testing.T) { + th := Setup(t).InitBasic().InitLogin() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPITriggerAdminNotifications = false }) + + statusCode, err := th.SystemAdminClient.TriggerNotifyAdmin(&model.NotifyAdminToUpgradeRequest{}) + + require.Error(t, err) + require.Equal(t, err.Error(), ": Internal error during cloud api request.") + require.Equal(t, http.StatusForbidden, statusCode) + + }) + + t.Run("error when non admins try to trigger notifications", func(t *testing.T) { + th := Setup(t).InitBasic().InitLogin() + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPITriggerAdminNotifications = true }) + + statusCode, err := th.Client.TriggerNotifyAdmin(&model.NotifyAdminToUpgradeRequest{}) + + require.Error(t, err) + require.Equal(t, err.Error(), ": You do not have the appropriate permissions.") + require.Equal(t, http.StatusForbidden, statusCode) + }) + + t.Run("happy path", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableAPITriggerAdminNotifications = true }) + + statusCode, err := th.Client.NotifyAdmin(&model.NotifyAdminToUpgradeRequest{ + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, statusCode) + + statusCode, err = th.SystemAdminClient.TriggerNotifyAdmin(&model.NotifyAdminToUpgradeRequest{}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, statusCode) + }) +} diff --git a/api4/user.go b/api4/user.go index ef35adf353..0cd58f56fb 100644 --- a/api4/user.go +++ b/api4/user.go @@ -103,6 +103,9 @@ func (api *API) InitUser() { api.BaseRoutes.UserThread.Handle("/following", api.APISessionRequired(unfollowThreadByUser)).Methods("DELETE") api.BaseRoutes.UserThread.Handle("/read/{timestamp:[0-9]+}", api.APISessionRequired(updateReadStateThreadByUser)).Methods("PUT") api.BaseRoutes.UserThread.Handle("/set_unread/{post_id:[A-Za-z0-9]+}", api.APISessionRequired(setUnreadThreadByPostId)).Methods("POST") + + api.BaseRoutes.Users.Handle("/notify-admin", api.APISessionRequired(handleNotifyAdmin)).Methods("POST") + api.BaseRoutes.Users.Handle("/trigger-notify-admin-posts", api.APISessionRequired(handleTriggerNotifyAdminPosts)).Methods("POST") } func createUser(c *Context, w http.ResponseWriter, r *http.Request) { diff --git a/app/app_iface.go b/app/app_iface.go index fa2a68696b..952362d63e 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -438,6 +438,7 @@ type AppIface interface { BulkExport(ctx request.CTX, writer io.Writer, outPath string, opts model.BulkExportOpts) *model.AppError BulkImport(c *request.Context, jsonlReader io.Reader, attachmentsReader *zip.Reader, dryRun bool, workers int) (*model.AppError, int) BulkImportWithPath(c *request.Context, jsonlReader io.Reader, attachmentsReader *zip.Reader, dryRun bool, workers int, importPath string) (*model.AppError, int) + CanNotifyAdmin(trial bool) bool CancelJob(jobId string) *model.AppError ChannelMembersToRemove(teamID *string) ([]*model.ChannelMember, *model.AppError) Channels() *Channels @@ -540,6 +541,7 @@ type AppIface interface { DisableAutoResponder(c request.CTX, userID string, asAdmin bool) *model.AppError DisableUserAccessToken(token *model.UserAccessToken) *model.AppError DoAppMigrations() + DoCheckForAdminNotifications(trial bool) *model.AppError DoCommandRequest(cmd *model.Command, p url.Values) (*model.Command, *model.CommandResponse, *model.AppError) DoEmojisPermissionsMigration() DoGuestRolesCreationMigration() @@ -564,6 +566,7 @@ type AppIface interface { FillInChannelsProps(c request.CTX, channelList model.ChannelList) *model.AppError FilterUsersByVisible(viewer *model.User, otherUsers []*model.User) ([]*model.User, *model.AppError) FindTeamByName(name string) bool + FinishSendAdminNotifyPost(trial bool, now int64) GenerateMfaSecret(userID string) (*model.MfaSecret, *model.AppError) GeneratePublicLink(siteURL string, info *model.FileInfo) string GenerateSupportPacket() []model.FileData @@ -907,7 +910,6 @@ type AppIface interface { NotificationsLog() *mlog.Logger NotifyAndSetWarnMetricAck(warnMetricId string, sender *model.User, forceAck bool, isBot bool) *model.AppError NotifySharedChannelUserUpdate(user *model.User) - NotifySystemAdminsToUpgrade(c *request.Context, currentUserTeamID string) *model.AppError OpenInteractiveDialog(request model.OpenDialogRequest) *model.AppError OriginChecker() func(*http.Request) bool PatchChannel(c request.CTX, channel *model.Channel, patch *model.ChannelPatch, userID string) (*model.Channel, *model.AppError) @@ -985,6 +987,8 @@ 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 + SaveAdminNotification(userId string, notifyData *model.NotifyAdminToUpgradeRequest) *model.AppError + SaveAdminNotifyData(data *model.NotifyAdminData) (*model.NotifyAdminData, *model.AppError) SaveAndBroadcastStatus(status *model.Status) SaveBrandImage(imageData *multipart.FileHeader) *model.AppError SaveComplianceReport(job *model.Compliance) (*model.Compliance, *model.AppError) @@ -1021,6 +1025,7 @@ type AppIface interface { SendEmailVerification(user *model.User, newEmail, redirect string) *model.AppError SendEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post SendNotifications(c request.CTX, post *model.Post, team *model.Team, channel *model.Channel, sender *model.User, parentPostList *model.PostList, setOnline bool) ([]string, error) + SendNotifyAdminPosts(c *request.Context, workspaceName string, currentSKU string, trial bool) *model.AppError SendPasswordReset(email string, siteURL string) (bool, *model.AppError) SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError SendTestPushNotification(deviceID string) string @@ -1139,6 +1144,7 @@ type AppIface interface { UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) UpsertGroupMembers(groupID string, userIDs []string) ([]*model.GroupMember, *model.AppError) UpsertGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) + UserAlreadyNotifiedOnRequiredFeature(user string, feature model.MattermostPaidFeature) bool UserCanSeeOtherUser(userID string, otherUserId string) (bool, *model.AppError) VerifyEmailFromToken(c request.CTX, userSuppliedTokenString string) *model.AppError VerifyUserEmail(userID, email string) *model.AppError diff --git a/app/cloud.go b/app/cloud.go index fed00db27b..2f95b73cc3 100644 --- a/app/cloud.go +++ b/app/cloud.go @@ -4,109 +4,16 @@ package app import ( - "encoding/json" "fmt" "net/http" - "strings" "time" - "github.com/mattermost/mattermost-server/v6/app/request" "github.com/mattermost/mattermost-server/v6/einterfaces" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/product" - "github.com/mattermost/mattermost-server/v6/shared/i18n" "github.com/mattermost/mattermost-server/v6/shared/mlog" ) -func (a *App) NotifySystemAdminsToUpgrade(c *request.Context, currentUserTeamID string) *model.AppError { - userId := c.Session().Id - - fakeId := strings.ReplaceAll(model.CloudNotifyAdminInfo, "_", "") + "123456" - - // check if already notified - notificationPref, err := a.Srv().Store.Preference().Get(fakeId, model.PreferenceCloudUserEphemeralInfo, model.CloudNotifyAdminInfo) - if err != nil { - mlog.Warn("Unable to get preference cloud_user_ephemeral_info", mlog.Err(err)) - } - - if notificationPref != nil { - info := &model.AdminNotificationUserInfo{} - err = json.Unmarshal([]byte(notificationPref.Value), info) - if err != nil { - mlog.Warn("Unable to Unmarshal", mlog.Err(err)) - } - - if !model.CanNotify(info.LastNotificationTimestamp) { - return model.NewAppError("app.NotifySystemAdminsToUpgrade", "api.cloud.notify_admin_to_upgrade_error.already_notified", nil, "", http.StatusForbidden) - } - } - - team, appErr := a.GetTeam(currentUserTeamID) - if appErr != nil { - return appErr - } - - sysadmins, appErr := a.GetUsersFromProfiles(&model.UserGetOptions{ - Page: 0, - PerPage: 100, - Role: model.SystemAdminRoleId, - Inactive: false, - }) - - if appErr != nil { - return appErr - } - - systemBot, appErr := a.GetSystemBot() - if appErr != nil { - return appErr - } - - for _, admin := range sysadmins { - T := i18n.GetUserTranslations(admin.Locale) - channel, appErr := a.GetOrCreateDirectChannel(c, systemBot.UserId, admin.Id) - if appErr != nil { - mlog.Warn("Error getting direct channel", mlog.Err(appErr)) - continue - } - - post := &model.Post{ - Message: T("api.cloud.upgrade_plan_bot_message", map[string]any{"TeamName": team.Name}), - UserId: systemBot.UserId, - ChannelId: channel.Id, - Type: fmt.Sprintf("%sup_notification", model.PostCustomTypePrefix), // webapp will have to create renderer for this custom post type - } - - _, appErr = a.CreatePost(c, post, channel, false, true) - if appErr != nil { - mlog.Warn("Error creating post", mlog.Err(appErr)) - continue - } - } - - // mark as done for current user until end of cool off period - out, err := json.Marshal(&model.AdminNotificationUserInfo{ - LastUserIDToNotify: userId, - LastNotificationTimestamp: model.GetMillis(), - }) - if err != nil { - mlog.Warn("Unable to Marshal", mlog.Err(err)) - } - - pref := model.Preference{ - UserId: fakeId, // to only have one preference for now and not a preference per user - Category: model.PreferenceCloudUserEphemeralInfo, - Name: model.CloudNotifyAdminInfo, - Value: string(out), - } - - if err := a.Srv().Store.Preference().Save(model.Preferences{pref}); err != nil { - mlog.Warn("Encountered error saving cloud_user_ephemeral_info preference", mlog.Err(err)) - } - - return nil -} - // Ensure cloud service wrapper implements `product.CloudService` var _ product.CloudService = (*cloudWrapper)(nil) diff --git a/app/notify_admin.go b/app/notify_admin.go new file mode 100644 index 0000000000..da1cecfd9c --- /dev/null +++ b/app/notify_admin.go @@ -0,0 +1,243 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + "fmt" + "net/http" + "os" + "strconv" + + "github.com/mattermost/mattermost-server/v6/app/request" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/i18n" + "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/store" +) + +const lastTrialNotificationTimeStamp = "LAST_TRIAL_NOTIFICATION_TIMESTAMP" +const lastUpgradeNotificationTimeStamp = "LAST_UPGRADE_NOTIFICATION_TIMESTAMP" +const defaultNotifyAdminCoolOffDays = 14 + +func (a *App) SaveAdminNotification(userId string, notifyData *model.NotifyAdminToUpgradeRequest) *model.AppError { + requiredFeature := notifyData.RequiredFeature + requiredPlan := notifyData.RequiredPlan + trial := notifyData.TrialNotification + + if a.UserAlreadyNotifiedOnRequiredFeature(userId, requiredFeature) { + return model.NewAppError("app.SaveAdminNotification", "api.cloud.notify_admin_to_upgrade_error.already_notified", nil, "", http.StatusForbidden) + } + + _, appErr := a.SaveAdminNotifyData(&model.NotifyAdminData{ + UserId: userId, + RequiredPlan: requiredPlan, + RequiredFeature: requiredFeature, + Trial: trial, + }) + if appErr != nil { + return appErr + } + + return nil +} + +func (a *App) DoCheckForAdminNotifications(trial bool) *model.AppError { + ctx := request.EmptyContext(a.Srv().Log()) + license := a.Srv().License() + if license == nil { + return model.NewAppError("DoCheckForAdminNotifications", "app.notify_admin.send_notification_post.app_error", nil, "No license found", http.StatusInternalServerError) + } + + currentSKU := license.SkuShortName + workspaceName := "" + + return a.SendNotifyAdminPosts(ctx, workspaceName, currentSKU, trial) +} + +func (a *App) SaveAdminNotifyData(data *model.NotifyAdminData) (*model.NotifyAdminData, *model.AppError) { + d, err := a.Srv().Store.NotifyAdmin().Save(data) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return nil, model.NewAppError("SaveAdminNotifyData", "app.notify_admin.save.app_error", nil, nfErr.Error(), http.StatusNotFound) + default: + return nil, model.NewAppError("SaveAdminNotifyData", "app.notify_admin.save.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + + return d, nil +} + +func filterNotificationData(data []*model.NotifyAdminData, test func(*model.NotifyAdminData) bool) (ret []*model.NotifyAdminData) { + for _, d := range data { + if test(d) { + ret = append(ret, d) + } + } + return +} + +func (a *App) SendNotifyAdminPosts(c *request.Context, workspaceName string, currentSKU string, trial bool) *model.AppError { + if !a.CanNotifyAdmin(trial) { + return model.NewAppError("SendNotifyAdminPosts", "app.notify_admin.send_notification_post.app_error", nil, "Cannot notify yet", http.StatusForbidden) + } + + sysadmins, appErr := a.GetUsersFromProfiles(&model.UserGetOptions{ + Page: 0, + PerPage: 100, + Role: model.SystemAdminRoleId, + Inactive: false, + }) + if appErr != nil { + return appErr + } + + systemBot, appErr := a.GetSystemBot() + if appErr != nil { + return appErr + } + + now := model.GetMillis() + + data, err := a.Srv().Store.NotifyAdmin().Get(trial) + if err != nil { + return model.NewAppError("SendNotifyAdminPosts", "app.notify_admin.send_notification_post.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + data = filterNotificationData(data, func(nad *model.NotifyAdminData) bool { return nad.RequiredPlan != currentSKU }) + + if len(data) == 0 { + mlog.Warn("No notification data available") + return nil + } + + userBasedData := a.groupNotifyAdminByUser(data) + featureBasedData := a.groupNotifyAdminByFeature(data) + props := make(model.StringInterface) + + for _, admin := range sysadmins { + T := i18n.GetUserTranslations(admin.Locale) + message := T("app.cloud.upgrade_plan_bot_message", map[string]interface{}{"UsersNum": len(userBasedData), "WorkspaceName": workspaceName}) + if len(userBasedData) == 1 { + message = T("app.cloud.upgrade_plan_bot_message_single", map[string]interface{}{"UsersNum": len(userBasedData), "WorkspaceName": workspaceName}) // todo (allan): investigate if translations library can do this + } + if trial { + message = T("app.cloud.trial_plan_bot_message", map[string]interface{}{"UsersNum": len(userBasedData), "WorkspaceName": workspaceName}) + if len(userBasedData) == 1 { + message = T("app.cloud.trial_plan_bot_message_single", map[string]interface{}{"UsersNum": len(userBasedData), "WorkspaceName": workspaceName}) + } + } + + channel, appErr := a.GetOrCreateDirectChannel(c, systemBot.UserId, admin.Id) + if appErr != nil { + mlog.Warn("Error getting direct channel", mlog.Err(appErr)) + continue + } + + post := &model.Post{ + Message: message, + UserId: systemBot.UserId, + ChannelId: channel.Id, + Type: fmt.Sprintf("%sup_notification", model.PostCustomTypePrefix), // webapp will have to create renderer for this custom post type + + } + + props["requested_features"] = featureBasedData + props["trial"] = trial + post.SetProps(props) + + _, appErr = a.CreatePost(c, post, channel, false, true) + if appErr != nil { + mlog.Warn("Error creating post", mlog.Err(appErr)) + continue + } + } + + a.FinishSendAdminNotifyPost(trial, now) + + return nil +} + +func (a *App) UserAlreadyNotifiedOnRequiredFeature(user string, feature model.MattermostPaidFeature) bool { + data, err := a.Srv().Store.NotifyAdmin().GetDataByUserIdAndFeature(user, feature) + if err != nil { + return false + } + if len(data) > 0 { + return true // if we find data, it means this user already notified on the need for this feature + } + + return false +} + +func (a *App) CanNotifyAdmin(trial bool) bool { + systemVarName := lastUpgradeNotificationTimeStamp + if trial { + systemVarName = lastTrialNotificationTimeStamp + } + + sysVal, sysValErr := a.Srv().Store.System().GetByName(systemVarName) + if sysValErr != nil { + var nfErr *store.ErrNotFound + if errors.As(sysValErr, &nfErr) { // if no timestamps have been recorded before, system is free to notify + return true + } + mlog.Error("Cannot notify", mlog.Err(sysValErr)) + return false + } + + lastNotificationTimestamp, err := strconv.ParseFloat(sysVal.Value, 64) + if err != nil { + mlog.Error("Cannot notify", mlog.Err(err)) + return false + } + + coolOffPeriodDaysEnv := os.Getenv("MM_NOTIFY_ADMIN_COOL_OFF_DAYS") + coolOffPeriodDays, parseError := strconv.ParseFloat(coolOffPeriodDaysEnv, 64) + if parseError != nil { + coolOffPeriodDays = defaultNotifyAdminCoolOffDays + } + daysToMillis := coolOffPeriodDays * 24 * 60 * 60 * 1000 + timeDiff := model.GetMillis() - int64(lastNotificationTimestamp) + return timeDiff >= int64(daysToMillis) +} + +func (a *App) FinishSendAdminNotifyPost(trial bool, now int64) { + systemVarName := lastUpgradeNotificationTimeStamp + if trial { + systemVarName = lastTrialNotificationTimeStamp + } + + val := strconv.FormatInt(model.GetMillis(), 10) + sysVar := &model.System{Name: systemVarName, Value: val} + if err := a.Srv().Store.System().SaveOrUpdate(sysVar); err != nil { + mlog.Error("Unable to finish send admin notify post job", mlog.Err(err)) + } + + // all the notifications are now sent in a post and can safely be removed + if err := a.Srv().Store.NotifyAdmin().DeleteBefore(trial, now); err != nil { + mlog.Error("Unable to finish send admin notify post job", mlog.Err(err)) + } + +} + +func (a *App) groupNotifyAdminByUser(data []*model.NotifyAdminData) map[string][]*model.NotifyAdminData { + myMap := make(map[string][]*model.NotifyAdminData) + for _, d := range data { + myMap[d.UserId] = append(myMap[d.UserId], d) + } + + return myMap +} + +func (a *App) groupNotifyAdminByFeature(data []*model.NotifyAdminData) map[model.MattermostPaidFeature][]*model.NotifyAdminData { + myMap := make(map[model.MattermostPaidFeature][]*model.NotifyAdminData) + for _, d := range data { + myMap[d.RequiredFeature] = append(myMap[d.RequiredFeature], d) + } + + return myMap +} diff --git a/app/notify_admin_test.go b/app/notify_admin_test.go new file mode 100644 index 0000000000..39c7745b6e --- /dev/null +++ b/app/notify_admin_test.go @@ -0,0 +1,267 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/mattermost/mattermost-server/v6/app/request" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/stretchr/testify/require" +) + +func Test_SendNotifyAdminPosts(t *testing.T) { + + t.Run("no error sending upgrade post when no notifications are available", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + ctx := request.NewContext(context.Background(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.Session{}, nil) + err := th.App.SendNotifyAdminPosts(ctx, "", "", false) + require.Nil(t, err) + }) + + t.Run("no error sending trial post when do notifications are available", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + ctx := request.NewContext(context.Background(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.Session{}, nil) + err := th.App.SendNotifyAdminPosts(ctx, "", "", true) + require.Nil(t, err) + }) + + t.Run("successfully send upgrade notification", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + // some some notifications + _, appErr := th.App.SaveAdminNotifyData(&model.NotifyAdminData{ + UserId: th.BasicUser.Id, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureGuestAccounts, + }) + require.Nil(t, appErr) + + _, appErr = th.App.SaveAdminNotifyData(&model.NotifyAdminData{ + UserId: th.BasicUser2.Id, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureGuestAccounts, + }) + require.Nil(t, appErr) + + ctx := request.NewContext(context.Background(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.Session{}, nil) + appErr = th.App.SendNotifyAdminPosts(ctx, "test", "", false) + require.Nil(t, appErr) + + bot, appErr := th.App.GetSystemBot() + require.Nil(t, appErr) + + // message sending is async, wait time for it + var channel *model.Channel + var err error + var timeout = 5 * time.Second + begin := time.Now() + for { + if time.Since(begin) > timeout { + break + } + channel, err = th.App.Srv().Store.Channel().GetByName("", model.GetDMNameFromIds(bot.UserId, th.SystemAdminUser.Id), false) + if err == nil && channel != nil { + break + } + time.Sleep(100 * time.Millisecond) + } + require.NoError(t, err, "Expected message to have been sent within %d seconds", timeout) + + postList, err := th.App.Srv().Store.Post().GetPosts(model.GetPostsOptions{ChannelId: channel.Id, Page: 0, PerPage: 1}, false, map[string]bool{}) + require.NoError(t, err) + + post := postList.Posts[postList.Order[0]] + require.Equal(t, fmt.Sprintf("%sup_notification", model.PostCustomTypePrefix), post.Type) + require.Equal(t, bot.UserId, post.UserId) + require.Equal(t, "2 members of the test workspace have requested a workspace upgrade for: ", post.Message) + }) + + t.Run("successfully send trial notification", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + // some some notifications + _, appErr := th.App.SaveAdminNotifyData(&model.NotifyAdminData{ + UserId: th.BasicUser.Id, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + Trial: true, + }) + require.Nil(t, appErr) + + ctx := request.NewContext(context.Background(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.Session{}, nil) + appErr = th.App.SendNotifyAdminPosts(ctx, "test", "", true) + require.Nil(t, appErr) + + bot, appErr := th.App.GetSystemBot() + require.Nil(t, appErr) + + // message sending is async, wait time for it + var channel *model.Channel + var err error + var timeout = 5 * time.Second + begin := time.Now() + for { + if time.Since(begin) > timeout { + break + } + channel, err = th.App.Srv().Store.Channel().GetByName("", model.GetDMNameFromIds(bot.UserId, th.SystemAdminUser.Id), false) + if err == nil && channel != nil { + break + } + time.Sleep(100 * time.Millisecond) + } + require.NoError(t, err, "Expected message to have been sent within %d seconds", timeout) + + postList, err := th.App.Srv().Store.Post().GetPosts(model.GetPostsOptions{ChannelId: channel.Id, Page: 0, PerPage: 1}, false, map[string]bool{}) + require.NoError(t, err) + + post := postList.Posts[postList.Order[0]] + require.Equal(t, fmt.Sprintf("%sup_notification", model.PostCustomTypePrefix), post.Type) + require.Equal(t, bot.UserId, post.UserId) + require.Equal(t, "1 member of the test workspace has requested starting the Enterprise trial for access to: ", post.Message) + }) + + t.Run("error when trying to send post before end of cool off period", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + // some some notifications + _, appErr := th.App.SaveAdminNotifyData(&model.NotifyAdminData{ + UserId: th.BasicUser.Id, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + }) + require.Nil(t, appErr) + + ctx := request.NewContext(context.Background(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.Session{}, nil) + appErr = th.App.SendNotifyAdminPosts(ctx, "", "", false) + require.Nil(t, appErr) + + // add some more notifications while in cool off + _, appErr = th.App.SaveAdminNotifyData(&model.NotifyAdminData{ + UserId: th.BasicUser.Id, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureCustomUsergroups, + }) + require.Nil(t, appErr) + + // second time trying to notify is forbidden + appErr = th.App.SendNotifyAdminPosts(ctx, "", "", false) + require.NotNil(t, appErr) + require.Equal(t, appErr.Error(), "SendNotifyAdminPosts: Unable to send notification post., Cannot notify yet") + }) + + t.Run("can send post at the end of cool off period", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + os.Setenv("MM_NOTIFY_ADMIN_COOL_OFF_DAYS", "0.00003472222222") // set to 3 seconds + defer os.Unsetenv("MM_NOTIFY_ADMIN_COOL_OFF_DAYS") + + // some some notifications + _, appErr := th.App.SaveAdminNotifyData(&model.NotifyAdminData{ + UserId: th.BasicUser.Id, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + }) + require.Nil(t, appErr) + + ctx := request.NewContext(context.Background(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.Session{}, nil) + appErr = th.App.SendNotifyAdminPosts(ctx, "", "", false) + require.Nil(t, appErr) + + // add some more notifications while in cool off + _, appErr = th.App.SaveAdminNotifyData(&model.NotifyAdminData{ + UserId: th.BasicUser.Id, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureCustomUsergroups, + }) + require.Nil(t, appErr) + + time.Sleep(5 * time.Second) + + // no error sending second time + appErr = th.App.SendNotifyAdminPosts(ctx, "", "", false) + require.Nil(t, appErr) + }) + + t.Run("can filter notifications when plan changes within cool off period", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + // some some notifications + _, appErr := th.App.SaveAdminNotifyData(&model.NotifyAdminData{ + UserId: th.BasicUser.Id, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + Trial: false, + }) + require.Nil(t, appErr) + + _, appErr = th.App.SaveAdminNotifyData(&model.NotifyAdminData{ + UserId: th.BasicUser2.Id, + RequiredPlan: model.LicenseShortSkuEnterprise, + RequiredFeature: model.PaidFeatureAllEnterprisefeatures, + Trial: false, + }) + require.Nil(t, appErr) + + ctx := request.NewContext(context.Background(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.NewId(), model.Session{}, nil) + appErr = th.App.SendNotifyAdminPosts(ctx, "test", model.LicenseShortSkuProfessional, false) // try and send notification but workspace currentSKU has since changed to cloud-professional + require.Nil(t, appErr) + + bot, appErr := th.App.GetSystemBot() + require.Nil(t, appErr) + + // message sending is async, wait time for it + var channel *model.Channel + var err error + var timeout = 5 * time.Second + begin := time.Now() + for { + if time.Since(begin) > timeout { + break + } + channel, err = th.App.Srv().Store.Channel().GetByName("", model.GetDMNameFromIds(bot.UserId, th.SystemAdminUser.Id), false) + if err == nil && channel != nil { + break + } + time.Sleep(100 * time.Millisecond) + } + require.NoError(t, err, "Expected message to have been sent within %d seconds", timeout) + + postList, err := th.App.Srv().Store.Post().GetPosts(model.GetPostsOptions{ChannelId: channel.Id, Page: 0, PerPage: 1}, false, map[string]bool{}) + require.NoError(t, err) + + post := postList.Posts[postList.Order[0]] + require.Equal(t, fmt.Sprintf("%sup_notification", model.PostCustomTypePrefix), post.Type) + require.Equal(t, bot.UserId, post.UserId) + require.Equal(t, "1 member of the test workspace has requested a workspace upgrade for: ", post.Message) // expect only one member's notification even though 2 were added + }) +} diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index a02d4d443a..1869099fb7 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -1083,6 +1083,23 @@ func (a *OpenTracingAppLayer) BulkImportWithPath(c *request.Context, jsonlReader return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) CanNotifyAdmin(trial bool) bool { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CanNotifyAdmin") + + 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.CanNotifyAdmin(trial) + + return resultVar0 +} + func (a *OpenTracingAppLayer) CancelJob(jobId string) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CancelJob") @@ -3653,6 +3670,28 @@ func (a *OpenTracingAppLayer) DoAppMigrations() { a.app.DoAppMigrations() } +func (a *OpenTracingAppLayer) DoCheckForAdminNotifications(trial bool) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DoCheckForAdminNotifications") + + 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.DoCheckForAdminNotifications(trial) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) DoCommandRequest(cmd *model.Command, p url.Values) (*model.Command, *model.CommandResponse, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DoCommandRequest") @@ -4362,6 +4401,21 @@ func (a *OpenTracingAppLayer) FindTeamByName(name string) bool { return resultVar0 } +func (a *OpenTracingAppLayer) FinishSendAdminNotifyPost(trial bool, now int64) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.FinishSendAdminNotifyPost") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + a.app.FinishSendAdminNotifyPost(trial, now) +} + func (a *OpenTracingAppLayer) GenerateMfaSecret(userID string) (*model.MfaSecret, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GenerateMfaSecret") @@ -12485,28 +12539,6 @@ func (a *OpenTracingAppLayer) NotifySharedChannelUserUpdate(user *model.User) { a.app.NotifySharedChannelUserUpdate(user) } -func (a *OpenTracingAppLayer) NotifySystemAdminsToUpgrade(c *request.Context, currentUserTeamID string) *model.AppError { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.NotifySystemAdminsToUpgrade") - - 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.NotifySystemAdminsToUpgrade(c, currentUserTeamID) - - if resultVar0 != nil { - span.LogFields(spanlog.Error(resultVar0)) - ext.Error.Set(span, true) - } - - return resultVar0 -} - func (a *OpenTracingAppLayer) OpenInteractiveDialog(request model.OpenDialogRequest) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.OpenInteractiveDialog") @@ -14331,6 +14363,50 @@ func (a *OpenTracingAppLayer) SanitizeTeams(session model.Session, teams []*mode return resultVar0 } +func (a *OpenTracingAppLayer) SaveAdminNotification(userId string, notifyData *model.NotifyAdminToUpgradeRequest) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveAdminNotification") + + 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.SaveAdminNotification(userId, notifyData) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + +func (a *OpenTracingAppLayer) SaveAdminNotifyData(data *model.NotifyAdminData) (*model.NotifyAdminData, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveAdminNotifyData") + + 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.SaveAdminNotifyData(data) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) SaveAndBroadcastStatus(status *model.Status) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveAndBroadcastStatus") @@ -15189,6 +15265,28 @@ func (a *OpenTracingAppLayer) SendNotifications(c request.CTX, post *model.Post, return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) SendNotifyAdminPosts(c *request.Context, workspaceName string, currentSKU string, trial bool) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendNotifyAdminPosts") + + 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.SendNotifyAdminPosts(c, workspaceName, currentSKU, trial) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) SendPasswordReset(email string, siteURL string) (bool, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendPasswordReset") @@ -18003,6 +18101,23 @@ func (a *OpenTracingAppLayer) UpsertGroupSyncable(groupSyncable *model.GroupSync return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) UserAlreadyNotifiedOnRequiredFeature(user string, feature model.MattermostPaidFeature) bool { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UserAlreadyNotifiedOnRequiredFeature") + + 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.UserAlreadyNotifiedOnRequiredFeature(user, feature) + + return resultVar0 +} + func (a *OpenTracingAppLayer) UserCanSeeOtherUser(userID string, otherUserId string) (bool, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UserCanSeeOtherUser") diff --git a/app/server.go b/app/server.go index 135a3eb5df..b44c19e9b2 100644 --- a/app/server.go +++ b/app/server.go @@ -48,6 +48,7 @@ import ( "github.com/mattermost/mattermost-server/v6/jobs/import_process" "github.com/mattermost/mattermost-server/v6/jobs/last_accessible_post" "github.com/mattermost/mattermost-server/v6/jobs/migrations" + "github.com/mattermost/mattermost-server/v6/jobs/notify_admin" "github.com/mattermost/mattermost-server/v6/jobs/product_notices" "github.com/mattermost/mattermost-server/v6/jobs/resend_invitation_email" "github.com/mattermost/mattermost-server/v6/model" @@ -1846,6 +1847,18 @@ func (s *Server) initJobs() { last_accessible_post.MakeWorker(s.Jobs, s.License(), New(ServerConnector(s.Channels()))), last_accessible_post.MakeScheduler(s.Jobs, s.License()), ) + + s.Jobs.RegisterJobType( + model.JobTypeUpgradeNotifyAdmin, + notify_admin.MakeUpgradeNotifyWorker(s.Jobs, s.License(), New(ServerConnector(s.Channels()))), + notify_admin.MakeScheduler(s.Jobs, s.License(), model.JobTypeUpgradeNotifyAdmin), + ) + + s.Jobs.RegisterJobType( + model.JobTypeTrialNotifyAdmin, + notify_admin.MakeTrialNotifyWorker(s.Jobs, s.License(), New(ServerConnector(s.Channels()))), + notify_admin.MakeScheduler(s.Jobs, s.License(), model.JobTypeTrialNotifyAdmin), + ) } func (s *Server) TelemetryId() string { diff --git a/db/migrations/migrations.list b/db/migrations/migrations.list index a0b64dcc01..dcdb14f484 100644 --- a/db/migrations/migrations.list +++ b/db/migrations/migrations.list @@ -184,6 +184,8 @@ db/migrations/mysql/000091_create_post_reminder.down.sql db/migrations/mysql/000091_create_post_reminder.up.sql db/migrations/mysql/000092_add_createat_to_teammembers.down.sql db/migrations/mysql/000092_add_createat_to_teammembers.up.sql +db/migrations/mysql/000093_notify_admin.down.sql +db/migrations/mysql/000093_notify_admin.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 @@ -368,3 +370,5 @@ db/migrations/postgres/000091_create_post_reminder.down.sql db/migrations/postgres/000091_create_post_reminder.up.sql db/migrations/postgres/000092_add_createat_to_teamembers.down.sql db/migrations/postgres/000092_add_createat_to_teamembers.up.sql +db/migrations/postgres/000093_notify_admin.down.sql +db/migrations/postgres/000093_notify_admin.up.sql diff --git a/db/migrations/mysql/000093_notify_admin.down.sql b/db/migrations/mysql/000093_notify_admin.down.sql new file mode 100644 index 0000000000..f15593c4b4 --- /dev/null +++ b/db/migrations/mysql/000093_notify_admin.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS NotifyAdmin; diff --git a/db/migrations/mysql/000093_notify_admin.up.sql b/db/migrations/mysql/000093_notify_admin.up.sql new file mode 100644 index 0000000000..f9696f29b7 --- /dev/null +++ b/db/migrations/mysql/000093_notify_admin.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS NotifyAdmin ( + UserId varchar(26) NOT NULL, + CreateAt bigint(20) DEFAULT NULL, + RequiredPlan varchar(26) NOT NULL, + RequiredFeature varchar(100) NOT NULL, + Trial BOOLEAN NOT NULL, + PRIMARY KEY (UserId, RequiredFeature, RequiredPlan) +); diff --git a/db/migrations/postgres/000093_notify_admin.down.sql b/db/migrations/postgres/000093_notify_admin.down.sql new file mode 100644 index 0000000000..f15593c4b4 --- /dev/null +++ b/db/migrations/postgres/000093_notify_admin.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS NotifyAdmin; diff --git a/db/migrations/postgres/000093_notify_admin.up.sql b/db/migrations/postgres/000093_notify_admin.up.sql new file mode 100644 index 0000000000..319768539f --- /dev/null +++ b/db/migrations/postgres/000093_notify_admin.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS NotifyAdmin ( + UserId varchar(26) NOT NULL, + CreateAt bigint DEFAULT NULL, + RequiredPlan varchar(26) NOT NULL, + RequiredFeature varchar(100) NOT NULL, + Trial BOOLEAN NOT NULL, + PRIMARY KEY (UserId, RequiredFeature, RequiredPlan) +); diff --git a/i18n/en.json b/i18n/en.json index 3d8726af1f..6f98b980f6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -495,10 +495,6 @@ "id": "api.cloud.teams_limit_reached.restore", "translation": "Unable to restore team because teams limit has been reached" }, - { - "id": "api.cloud.upgrade_plan_bot_message", - "translation": "A member of {{.TeamName}} has notified you to upgrade this workspace." - }, { "id": "api.command.admin_only.app_error", "translation": "Integrations have been limited to admins only." @@ -4803,6 +4799,22 @@ "id": "app.cloud.get_subscription_delinquency_date.app_error", "translation": "Subscription is not delinquent" }, + { + "id": "app.cloud.trial_plan_bot_message", + "translation": "{{.UsersNum}} members of the {{.WorkspaceName}} workspace have requested starting the Enterprise trial for access to: " + }, + { + "id": "app.cloud.trial_plan_bot_message_single", + "translation": "{{.UsersNum}} member of the {{.WorkspaceName}} workspace has requested starting the Enterprise trial for access to: " + }, + { + "id": "app.cloud.upgrade_plan_bot_message", + "translation": "{{.UsersNum}} members of the {{.WorkspaceName}} workspace have requested a workspace upgrade for: " + }, + { + "id": "app.cloud.upgrade_plan_bot_message_single", + "translation": "{{.UsersNum}} member of the {{.WorkspaceName}} workspace has requested a workspace upgrade for: " + }, { "id": "app.command.createcommand.internal_error", "translation": "Unable to save the command." @@ -5719,6 +5731,14 @@ "id": "app.notification.subject.notification.full", "translation": "[{{ .SiteName }}] Notification in {{ .TeamName}} on {{.Month}} {{.Day}}, {{.Year}}" }, + { + "id": "app.notify_admin.save.app_error", + "translation": "Unable to save notify data." + }, + { + "id": "app.notify_admin.send_notification_post.app_error", + "translation": "Unable to send notification post." + }, { "id": "app.oauth.delete_app.app_error", "translation": "An error occurred while deleting the OAuth2 App." diff --git a/jobs/notify_admin/scheduler.go b/jobs/notify_admin/scheduler.go new file mode 100644 index 0000000000..3cb8320f67 --- /dev/null +++ b/jobs/notify_admin/scheduler.go @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package notify_admin + +import ( + "strconv" + "time" + + "github.com/mattermost/mattermost-server/v6/jobs" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" +) + +const schedFreq = 24 * time.Hour + +func MakeScheduler(jobServer *jobs.JobServer, license *model.License, jobType string) model.Scheduler { + isEnabled := func(cfg *model.Config) bool { + enabled := license != nil && *license.Features.Cloud + mlog.Debug("Scheduler: isEnabled: "+strconv.FormatBool(enabled), mlog.String("scheduler", jobType)) + return enabled + } + return jobs.NewPeriodicScheduler(jobServer, jobType, schedFreq, isEnabled) + +} diff --git a/jobs/notify_admin/worker.go b/jobs/notify_admin/worker.go new file mode 100644 index 0000000000..283ce43adf --- /dev/null +++ b/jobs/notify_admin/worker.go @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package notify_admin + +import ( + "github.com/mattermost/mattermost-server/v6/jobs" + "github.com/mattermost/mattermost-server/v6/model" +) + +const ( + UpgradeNotifyJobName = "UpgradeNotifyAdmin" + TrialNotifyJobName = "TrialNotifyAdmin" +) + +type AppIface interface { + DoCheckForAdminNotifications(trial bool) *model.AppError +} + +func MakeUpgradeNotifyWorker(jobServer *jobs.JobServer, license *model.License, app AppIface) model.Worker { + isEnabled := func(_ *model.Config) bool { + return license != nil && license.Features != nil && *license.Features.Cloud + } + execute := func(_ *model.Job) error { + appErr := app.DoCheckForAdminNotifications(false) + if appErr != nil { + return appErr + } + + return nil + } + worker := jobs.NewSimpleWorker(UpgradeNotifyJobName, jobServer, execute, isEnabled) + return worker +} + +func MakeTrialNotifyWorker(jobServer *jobs.JobServer, license *model.License, app AppIface) model.Worker { + isEnabled := func(_ *model.Config) bool { + return license != nil && license.Features != nil && *license.Features.Cloud + } + execute := func(_ *model.Job) error { + appErr := app.DoCheckForAdminNotifications(true) + if appErr != nil { + return appErr + } + + return nil + } + worker := jobs.NewSimpleWorker(TrialNotifyJobName, jobServer, execute, isEnabled) + return worker +} diff --git a/model/client4.go b/model/client4.go index 6f026bc0ac..9e069fa535 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8020,20 +8020,36 @@ func (c *Client4) ValidateWorkspaceBusinessEmail() (*Response, error) { return BuildResponse(r), nil } -func (c *Client4) NotifyAdmin(nr *NotifyAdminToUpgradeRequest) int { +func (c *Client4) NotifyAdmin(nr *NotifyAdminToUpgradeRequest) (int, error) { nrJSON, err := json.Marshal(nr) if err != nil { - return 0 + return 0, err } - r, err := c.DoAPIPost(c.cloudRoute()+"/notify-admin-to-upgrade", string(nrJSON)) + r, err := c.DoAPIPost("/users/notify-admin", string(nrJSON)) if err != nil { - return r.StatusCode + return r.StatusCode, err } closeBody(r) - return r.StatusCode + return r.StatusCode, nil +} + +func (c *Client4) TriggerNotifyAdmin(nr *NotifyAdminToUpgradeRequest) (int, error) { + nrJSON, err := json.Marshal(nr) + if err != nil { + return 0, err + } + + r, err := c.DoAPIPost("/users/trigger-notify-admin-posts", string(nrJSON)) + if err != nil { + return r.StatusCode, err + } + + closeBody(r) + + return r.StatusCode, nil } func (c *Client4) ValidateBusinessEmail(email *ValidateBusinessEmailRequest) (*Response, error) { diff --git a/model/cloud.go b/model/cloud.go index 940da261e8..a9e8a2a4cf 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -4,8 +4,6 @@ package model import ( - "os" - "strconv" "strings" ) @@ -42,9 +40,6 @@ const ( SubscriptionFamilyOnPrem = SubscriptionFamily("on-prem") ) -const defaultCloudNotifyAdminCoolOffDays = 30 -const CloudNotifyAdminInfo = "cloud_notify_admin_info" - // Product model represents a product on the cloud system. type Product struct { ID string `json:"id"` @@ -258,23 +253,3 @@ type ProductLimits struct { Messages *MessagesLimits `json:"messages,omitempty"` Teams *TeamsLimits `json:"teams,omitempty"` } - -type NotifyAdminToUpgradeRequest struct { - CurrentTeamId string `json:"current_team_id"` -} - -type AdminNotificationUserInfo struct { - LastUserIDToNotify string - LastNotificationTimestamp int64 -} - -func CanNotify(lastNotificationTimestamp int64) bool { - coolOffPeriodDaysEnv := os.Getenv("MM_CLOUD_NOTIFY_ADMIN_COOL_OFF_DAYS") - coolOffPeriodDays, parseError := strconv.ParseFloat(coolOffPeriodDaysEnv, 64) - if parseError != nil { - coolOffPeriodDays = defaultCloudNotifyAdminCoolOffDays - } - daysToMillis := coolOffPeriodDays * 24 * 60 * 60 * 1000 - timeDiff := GetMillis() - lastNotificationTimestamp - return timeDiff >= int64(daysToMillis) -} diff --git a/model/config.go b/model/config.go index 096bff3858..b117548a67 100644 --- a/model/config.go +++ b/model/config.go @@ -361,6 +361,7 @@ type ServiceSettings struct { ExperimentalEnableDefaultChannelLeaveJoinMessages *bool `access:"experimental_features"` ExperimentalGroupUnreadChannels *string `access:"experimental_features"` EnableAPITeamDeletion *bool + EnableAPITriggerAdminNotifications *bool EnableAPIUserDeletion *bool ExperimentalEnableHardenedMode *bool `access:"experimental_features"` ExperimentalStrictCSRFEnforcement *bool `access:"experimental_features,write_restrictable,cloud_restrictable"` @@ -754,6 +755,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { s.EnableAPITeamDeletion = NewBool(false) } + if s.EnableAPITriggerAdminNotifications == nil { + s.EnableAPITriggerAdminNotifications = NewBool(false) + } + if s.EnableAPIUserDeletion == nil { s.EnableAPIUserDeletion = NewBool(false) } diff --git a/model/job.go b/model/job.go index b6cdbd5ee3..0868643557 100644 --- a/model/job.go +++ b/model/job.go @@ -28,6 +28,8 @@ const ( JobTypeResendInvitationEmail = "resend_invitation_email" JobTypeExtractContent = "extract_content" JobTypeLastAccessiblePost = "last_accessible_post" + JobTypeUpgradeNotifyAdmin = "upgrade_notify_admin" + JobTypeTrialNotifyAdmin = "trial_notify_admin" JobStatusPending = "pending" JobStatusInProgress = "in_progress" diff --git a/model/notify_admin.go b/model/notify_admin.go new file mode 100644 index 0000000000..729050a804 --- /dev/null +++ b/model/notify_admin.go @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "fmt" + "net/http" +) + +type MattermostPaidFeature string + +const ( + PaidFeatureGuestAccounts = MattermostPaidFeature("mattermost.feature.guest_accounts") + PaidFeatureCustomUsergroups = MattermostPaidFeature("mattermost.feature.custom_user_groups") + PaidFeatureCreateMultipleTeams = MattermostPaidFeature("mattermost.feature.create_multiple_teams") + PaidFeatureStartcall = MattermostPaidFeature("mattermost.feature.start_call") + PaidFeaturePlaybooksRetrospective = MattermostPaidFeature("mattermost.feature.playbooks_retro") + PaidFeatureUnlimitedMessages = MattermostPaidFeature("mattermost.feature.unlimited_messages") + PaidFeatureUnlimitedFileStorage = MattermostPaidFeature("mattermost.feature.unlimited_file_storage") + PaidFeatureUnlimitedIntegrations = MattermostPaidFeature("mattermost.feature.unlimited_integrations") + PaidFeatureUnlimitedBoardcards = MattermostPaidFeature("mattermost.feature.unlimited_board_cards") + PaidFeatureAllProfessionalfeatures = MattermostPaidFeature("mattermost.feature.all_professional") + PaidFeatureAllEnterprisefeatures = MattermostPaidFeature("mattermost.feature.all_enterprise") +) + +var validSKUs map[string]struct{} = map[string]struct{}{ + LicenseShortSkuProfessional: {}, + LicenseShortSkuEnterprise: {}, +} + +// These are the features a non admin would typically ping an admin about +var paidFeatures map[MattermostPaidFeature]struct{} = map[MattermostPaidFeature]struct{}{ + PaidFeatureGuestAccounts: {}, + PaidFeatureCustomUsergroups: {}, + PaidFeatureCreateMultipleTeams: {}, + PaidFeatureStartcall: {}, + PaidFeaturePlaybooksRetrospective: {}, + PaidFeatureUnlimitedMessages: {}, + PaidFeatureUnlimitedFileStorage: {}, + PaidFeatureUnlimitedIntegrations: {}, + PaidFeatureUnlimitedBoardcards: {}, + PaidFeatureAllProfessionalfeatures: {}, + PaidFeatureAllEnterprisefeatures: {}, +} + +type NotifyAdminToUpgradeRequest struct { + TrialNotification bool `json:"trial_notification"` + RequiredPlan string `json:"required_plan"` + RequiredFeature MattermostPaidFeature `json:"required_feature"` +} + +type NotifyAdminData struct { + CreateAt int64 `json:"create_at,omitempty"` + UserId string `json:"user_id"` + RequiredPlan string `json:"required_plan"` + RequiredFeature MattermostPaidFeature `json:"required_feature"` + Trial bool `json:"trial"` +} + +func (nad *NotifyAdminData) IsValid() *AppError { + if _, planOk := validSKUs[nad.RequiredPlan]; !planOk { + return NewAppError("NotifyAdmin.IsValid", fmt.Sprintf("Invalid plan, %s provided", nad.RequiredPlan), nil, "", http.StatusBadRequest) + } + + if _, featureOk := paidFeatures[nad.RequiredFeature]; !featureOk { + return NewAppError("NotifyAdmin.IsValid", fmt.Sprintf("Invalid feature, %s provided", nad.RequiredFeature), nil, "", http.StatusBadRequest) + } + + return nil +} + +func (nad *NotifyAdminData) PreSave() { + nad.CreateAt = GetMillis() +} diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index f979cf938f..fc58db9005 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -428,6 +428,7 @@ func (ts *TelemetryService) trackConfig() { "websocket_url": isDefault(*cfg.ServiceSettings.WebsocketURL, ""), "allow_cookies_for_subdomains": *cfg.ServiceSettings.AllowCookiesForSubdomains, "enable_api_team_deletion": *cfg.ServiceSettings.EnableAPITeamDeletion, + "enable_api_trigger_admin_notification": *cfg.ServiceSettings.EnableAPITriggerAdminNotifications, "enable_api_user_deletion": *cfg.ServiceSettings.EnableAPIUserDeletion, "enable_api_channel_deletion": *cfg.ServiceSettings.EnableAPIChannelDeletion, "experimental_enable_hardened_mode": *cfg.ServiceSettings.ExperimentalEnableHardenedMode, diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 79b10e0182..51a2dd1055 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -33,6 +33,7 @@ type OpenTracingLayer struct { JobStore store.JobStore LicenseStore store.LicenseStore LinkMetadataStore store.LinkMetadataStore + NotifyAdminStore store.NotifyAdminStore OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore @@ -114,6 +115,10 @@ func (s *OpenTracingLayer) LinkMetadata() store.LinkMetadataStore { return s.LinkMetadataStore } +func (s *OpenTracingLayer) NotifyAdmin() store.NotifyAdminStore { + return s.NotifyAdminStore +} + func (s *OpenTracingLayer) OAuth() store.OAuthStore { return s.OAuthStore } @@ -276,6 +281,11 @@ type OpenTracingLayerLinkMetadataStore struct { Root *OpenTracingLayer } +type OpenTracingLayerNotifyAdminStore struct { + store.NotifyAdminStore + Root *OpenTracingLayer +} + type OpenTracingLayerOAuthStore struct { store.OAuthStore Root *OpenTracingLayer @@ -4970,6 +4980,78 @@ func (s *OpenTracingLayerLinkMetadataStore) Save(linkMetadata *model.LinkMetadat return result, err } +func (s *OpenTracingLayerNotifyAdminStore) DeleteBefore(trial bool, now int64) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "NotifyAdminStore.DeleteBefore") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.NotifyAdminStore.DeleteBefore(trial, now) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerNotifyAdminStore) Get(trial bool) ([]*model.NotifyAdminData, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "NotifyAdminStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.NotifyAdminStore.Get(trial) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerNotifyAdminStore) GetDataByUserIdAndFeature(userId string, feature model.MattermostPaidFeature) ([]*model.NotifyAdminData, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "NotifyAdminStore.GetDataByUserIdAndFeature") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.NotifyAdminStore.GetDataByUserIdAndFeature(userId, feature) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerNotifyAdminStore) Save(data *model.NotifyAdminData) (*model.NotifyAdminData, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "NotifyAdminStore.Save") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.NotifyAdminStore.Save(data) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerOAuthStore) DeleteApp(id string) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "OAuthStore.DeleteApp") @@ -12333,6 +12415,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.JobStore = &OpenTracingLayerJobStore{JobStore: childStore.Job(), Root: &newStore} newStore.LicenseStore = &OpenTracingLayerLicenseStore{LicenseStore: childStore.License(), Root: &newStore} newStore.LinkMetadataStore = &OpenTracingLayerLinkMetadataStore{LinkMetadataStore: childStore.LinkMetadata(), Root: &newStore} + newStore.NotifyAdminStore = &OpenTracingLayerNotifyAdminStore{NotifyAdminStore: childStore.NotifyAdmin(), Root: &newStore} newStore.OAuthStore = &OpenTracingLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &OpenTracingLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &OpenTracingLayerPostStore{PostStore: childStore.Post(), Root: &newStore} diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 9dfa6a19cd..2506d610f9 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -36,6 +36,7 @@ type RetryLayer struct { JobStore store.JobStore LicenseStore store.LicenseStore LinkMetadataStore store.LinkMetadataStore + NotifyAdminStore store.NotifyAdminStore OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore @@ -117,6 +118,10 @@ func (s *RetryLayer) LinkMetadata() store.LinkMetadataStore { return s.LinkMetadataStore } +func (s *RetryLayer) NotifyAdmin() store.NotifyAdminStore { + return s.NotifyAdminStore +} + func (s *RetryLayer) OAuth() store.OAuthStore { return s.OAuthStore } @@ -279,6 +284,11 @@ type RetryLayerLinkMetadataStore struct { Root *RetryLayer } +type RetryLayerNotifyAdminStore struct { + store.NotifyAdminStore + Root *RetryLayer +} + type RetryLayerOAuthStore struct { store.OAuthStore Root *RetryLayer @@ -5633,6 +5643,90 @@ func (s *RetryLayerLinkMetadataStore) Save(linkMetadata *model.LinkMetadata) (*m } +func (s *RetryLayerNotifyAdminStore) DeleteBefore(trial bool, now int64) error { + + tries := 0 + for { + err := s.NotifyAdminStore.DeleteBefore(trial, now) + 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 *RetryLayerNotifyAdminStore) Get(trial bool) ([]*model.NotifyAdminData, error) { + + tries := 0 + for { + result, err := s.NotifyAdminStore.Get(trial) + 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 *RetryLayerNotifyAdminStore) GetDataByUserIdAndFeature(userId string, feature model.MattermostPaidFeature) ([]*model.NotifyAdminData, error) { + + tries := 0 + for { + result, err := s.NotifyAdminStore.GetDataByUserIdAndFeature(userId, feature) + 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 *RetryLayerNotifyAdminStore) Save(data *model.NotifyAdminData) (*model.NotifyAdminData, error) { + + tries := 0 + for { + result, err := s.NotifyAdminStore.Save(data) + 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 *RetryLayerOAuthStore) DeleteApp(id string) error { tries := 0 @@ -14058,6 +14152,7 @@ func New(childStore store.Store) *RetryLayer { newStore.JobStore = &RetryLayerJobStore{JobStore: childStore.Job(), Root: &newStore} newStore.LicenseStore = &RetryLayerLicenseStore{LicenseStore: childStore.License(), Root: &newStore} newStore.LinkMetadataStore = &RetryLayerLinkMetadataStore{LinkMetadataStore: childStore.LinkMetadata(), Root: &newStore} + newStore.NotifyAdminStore = &RetryLayerNotifyAdminStore{NotifyAdminStore: childStore.NotifyAdmin(), Root: &newStore} newStore.OAuthStore = &RetryLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &RetryLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &RetryLayerPostStore{PostStore: childStore.Post(), Root: &newStore} diff --git a/store/retrylayer/retrylayer_test.go b/store/retrylayer/retrylayer_test.go index 0d2f95bad5..f71ce9a55d 100644 --- a/store/retrylayer/retrylayer_test.go +++ b/store/retrylayer/retrylayer_test.go @@ -53,6 +53,7 @@ func genStore() *mocks.Store { mock.On("UserAccessToken").Return(&mocks.UserAccessTokenStore{}) mock.On("UserTermsOfService").Return(&mocks.UserTermsOfServiceStore{}) mock.On("Webhook").Return(&mocks.WebhookStore{}) + mock.On("NotifyAdmin").Return(&mocks.NotifyAdminStore{}) return mock } diff --git a/store/sqlstore/notify_admin_store.go b/store/sqlstore/notify_admin_store.go new file mode 100644 index 0000000000..e08337e746 --- /dev/null +++ b/store/sqlstore/notify_admin_store.go @@ -0,0 +1,87 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + "fmt" + + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + sq "github.com/mattermost/squirrel" +) + +type SqlNotifyAdminStore struct { + *SqlStore +} + +func newSqlNotifyAdminStore(sqlStore *SqlStore) store.NotifyAdminStore { + return &SqlNotifyAdminStore{sqlStore} +} + +func (s SqlNotifyAdminStore) insert(data *model.NotifyAdminData) (sql.Result, error) { + query := `INSERT INTO NotifyAdmin (UserId, CreateAt, RequiredPlan, RequiredFeature, Trial) VALUES (:UserId, :CreateAt, :RequiredPlan, :RequiredFeature, :Trial)` + return s.GetMasterX().NamedExec(query, data) +} + +func (s SqlNotifyAdminStore) Save(data *model.NotifyAdminData) (*model.NotifyAdminData, error) { + if err := data.IsValid(); err != nil { + return nil, err + } + + data.PreSave() + + _, err := s.insert(data) + if err != nil { + return nil, errors.Wrap(err, "failed to save Notify Admin data") + } + + return data, nil +} + +func (s SqlNotifyAdminStore) GetDataByUserIdAndFeature(userId string, feature model.MattermostPaidFeature) ([]*model.NotifyAdminData, error) { + data := []*model.NotifyAdminData{} + query, args, err := s.getQueryBuilder(). + Select("*"). + From("NotifyAdmin"). + Where(sq.Eq{"UserId": userId, "RequiredFeature": feature}). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "could not build sql query to get all notifcation data by user id and required feature") + } + + if err := s.GetReplicaX().Select(&data, query, args...); err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("NotifyAdmin", fmt.Sprintf("user id: %s and required feature: %s", userId, feature)) + } + return nil, errors.Wrapf(err, "notifcation data by user id: %s and required feature: %s", userId, feature) + } + return data, nil +} + +func (s SqlNotifyAdminStore) Get(trial bool) ([]*model.NotifyAdminData, error) { + data := []*model.NotifyAdminData{} + query, args, err := s.getQueryBuilder(). + Select("*"). + From("NotifyAdmin"). + Where(sq.Eq{"trial": trial}). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "could not build sql query to get all notifcation data") + } + + if err := s.GetReplicaX().Select(&data, query, args...); err != nil { + return nil, errors.Wrap(err, "notifcation data") + } + return data, nil +} + +func (s SqlNotifyAdminStore) DeleteBefore(trial bool, now int64) error { + if _, err := s.GetMasterX().Exec("DELETE FROM NotifyAdmin WHERE trial = ? AND createat < ?", trial, now); err != nil { + return errors.Wrapf(err, "failed to remove all notification data with trial=%t", trial) + } + return nil +} diff --git a/store/sqlstore/notify_admin_store_test.go b/store/sqlstore/notify_admin_store_test.go new file mode 100644 index 0000000000..3c1e46501e --- /dev/null +++ b/store/sqlstore/notify_admin_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 TestNotifyAdminStore(t *testing.T) { + StoreTest(t, storetest.TestNotifyAdminStore) +} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index eea25d0ce1..58950dbb48 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -108,6 +108,7 @@ type SqlStoreStores struct { UserTermsOfService store.UserTermsOfServiceStore linkMetadata store.LinkMetadataStore sharedchannel store.SharedChannelStore + notifyAdmin store.NotifyAdminStore } type SqlStore struct { @@ -212,6 +213,7 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS store.stores.scheme = newSqlSchemeStore(store) store.stores.group = newSqlGroupStore(store) store.stores.productNotices = newSqlProductNoticesStore(store) + store.stores.notifyAdmin = newSqlNotifyAdminStore(store) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -914,6 +916,10 @@ func (ss *SqlStore) LinkMetadata() store.LinkMetadataStore { return ss.stores.linkMetadata } +func (ss *SqlStore) NotifyAdmin() store.NotifyAdminStore { + return ss.stores.notifyAdmin +} + func (ss *SqlStore) SharedChannel() store.SharedChannelStore { return ss.stores.sharedchannel } diff --git a/store/store.go b/store/store.go index 99882d34b9..0e392bf861 100644 --- a/store/store.go +++ b/store/store.go @@ -82,6 +82,7 @@ type Store interface { CheckIntegrity() <-chan model.IntegrityCheckResult SetContext(context context.Context) Context() context.Context + NotifyAdmin() NotifyAdminStore } type RetentionPolicyStore interface { @@ -923,6 +924,13 @@ type LinkMetadataStore interface { Get(url string, timestamp int64) (*model.LinkMetadata, error) } +type NotifyAdminStore interface { + Save(data *model.NotifyAdminData) (*model.NotifyAdminData, error) + GetDataByUserIdAndFeature(userId string, feature model.MattermostPaidFeature) ([]*model.NotifyAdminData, error) + Get(trial bool) ([]*model.NotifyAdminData, error) + DeleteBefore(trial bool, now int64) error +} + type SharedChannelStore interface { Save(sc *model.SharedChannel) (*model.SharedChannel, error) Get(channelId string) (*model.SharedChannel, error) diff --git a/store/storetest/mocks/NotifyAdminStore.go b/store/storetest/mocks/NotifyAdminStore.go new file mode 100644 index 0000000000..4a3529103a --- /dev/null +++ b/store/storetest/mocks/NotifyAdminStore.go @@ -0,0 +1,98 @@ +// 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" +) + +// NotifyAdminStore is an autogenerated mock type for the NotifyAdminStore type +type NotifyAdminStore struct { + mock.Mock +} + +// DeleteBefore provides a mock function with given fields: trial, now +func (_m *NotifyAdminStore) DeleteBefore(trial bool, now int64) error { + ret := _m.Called(trial, now) + + var r0 error + if rf, ok := ret.Get(0).(func(bool, int64) error); ok { + r0 = rf(trial, now) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: trial +func (_m *NotifyAdminStore) Get(trial bool) ([]*model.NotifyAdminData, error) { + ret := _m.Called(trial) + + var r0 []*model.NotifyAdminData + if rf, ok := ret.Get(0).(func(bool) []*model.NotifyAdminData); ok { + r0 = rf(trial) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.NotifyAdminData) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(bool) error); ok { + r1 = rf(trial) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetDataByUserIdAndFeature provides a mock function with given fields: userId, feature +func (_m *NotifyAdminStore) GetDataByUserIdAndFeature(userId string, feature model.MattermostPaidFeature) ([]*model.NotifyAdminData, error) { + ret := _m.Called(userId, feature) + + var r0 []*model.NotifyAdminData + if rf, ok := ret.Get(0).(func(string, model.MattermostPaidFeature) []*model.NotifyAdminData); ok { + r0 = rf(userId, feature) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.NotifyAdminData) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, model.MattermostPaidFeature) error); ok { + r1 = rf(userId, feature) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Save provides a mock function with given fields: data +func (_m *NotifyAdminStore) Save(data *model.NotifyAdminData) (*model.NotifyAdminData, error) { + ret := _m.Called(data) + + var r0 *model.NotifyAdminData + if rf, ok := ret.Get(0).(func(*model.NotifyAdminData) *model.NotifyAdminData); ok { + r0 = rf(data) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.NotifyAdminData) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.NotifyAdminData) error); ok { + r1 = rf(data) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/store/storetest/mocks/Store.go b/store/storetest/mocks/Store.go index 05998c1458..d099b9f640 100644 --- a/store/storetest/mocks/Store.go +++ b/store/storetest/mocks/Store.go @@ -395,6 +395,22 @@ func (_m *Store) MarkSystemRanUnitTests() { _m.Called() } +// NotifyAdmin provides a mock function with given fields: +func (_m *Store) NotifyAdmin() store.NotifyAdminStore { + ret := _m.Called() + + var r0 store.NotifyAdminStore + if rf, ok := ret.Get(0).(func() store.NotifyAdminStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.NotifyAdminStore) + } + } + + return r0 +} + // OAuth provides a mock function with given fields: func (_m *Store) OAuth() store.OAuthStore { ret := _m.Called() diff --git a/store/storetest/notify_admin_store.go b/store/storetest/notify_admin_store.go new file mode 100644 index 0000000000..2c6d19d3e8 --- /dev/null +++ b/store/storetest/notify_admin_store.go @@ -0,0 +1,194 @@ +// 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 TestNotifyAdminStore(t *testing.T, ss store.Store) { + t.Run("Save", func(t *testing.T) { testNotifyAdminStoreSave(t, ss) }) + t.Run("testGetDataByUserIdAndFeature", func(t *testing.T) { testGetDataByUserIdAndFeature(t, ss) }) + t.Run("testGet", func(t *testing.T) { testGet(t, ss) }) + t.Run("testDeleteBefore", func(t *testing.T) { testDeleteBefore(t, ss) }) +} + +func tearDown(t *testing.T, ss store.Store) { + err := ss.NotifyAdmin().DeleteBefore(true, model.GetMillis()+model.GetMillis()) + require.NoError(t, err) + + err = ss.NotifyAdmin().DeleteBefore(false, model.GetMillis()+model.GetMillis()) + require.NoError(t, err) +} + +func testNotifyAdminStoreSave(t *testing.T, ss store.Store) { + d1 := &model.NotifyAdminData{ + UserId: model.NewId(), + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + } + + _, err := ss.NotifyAdmin().Save(d1) + require.NoError(t, err) + + // unknow plan error + d2 := &model.NotifyAdminData{ + UserId: model.NewId(), + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: "Unknown feature", + } + + _, err = ss.NotifyAdmin().Save(d2) + require.Error(t, err) + + // unknown feature error + d3 := &model.NotifyAdminData{ + UserId: model.NewId(), + RequiredPlan: "Unknown plan", + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + } + _, err = ss.NotifyAdmin().Save(d3) + require.Error(t, err) + + // same user requesting same feature error + singleUserId := model.NewId() + d5 := &model.NotifyAdminData{ + UserId: singleUserId, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + } + _, err = ss.NotifyAdmin().Save(d5) + require.NoError(t, err) + + d6 := &model.NotifyAdminData{ + UserId: singleUserId, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + } + _, err = ss.NotifyAdmin().Save(d6) + require.Error(t, err) + + tearDown(t, ss) +} + +func testGet(t *testing.T, ss store.Store) { + userId1 := model.NewId() + d1 := &model.NotifyAdminData{ + UserId: userId1, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + } + + _, err := ss.NotifyAdmin().Save(d1) + require.NoError(t, err) + + d1Trial := &model.NotifyAdminData{ + UserId: userId1, + RequiredPlan: model.LicenseShortSkuEnterprise, + RequiredFeature: model.PaidFeatureAllEnterprisefeatures, + Trial: true, + } + _, err = ss.NotifyAdmin().Save(d1Trial) + require.NoError(t, err) + + d1Trial2 := &model.NotifyAdminData{ + UserId: model.NewId(), + RequiredPlan: model.LicenseShortSkuEnterprise, + RequiredFeature: model.PaidFeatureAllEnterprisefeatures, + Trial: true, + } + _, err = ss.NotifyAdmin().Save(d1Trial2) + require.NoError(t, err) + + upgradeRequests, err := ss.NotifyAdmin().Get(false) + require.NoError(t, err) + require.Equal(t, len(upgradeRequests), 1) + + trialRequests, err := ss.NotifyAdmin().Get(true) + require.NoError(t, err) + require.Equal(t, len(trialRequests), 2) + + tearDown(t, ss) +} + +func testGetDataByUserIdAndFeature(t *testing.T, ss store.Store) { + userId1 := model.NewId() + d1 := &model.NotifyAdminData{ + UserId: userId1, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + } + + _, err := ss.NotifyAdmin().Save(d1) + require.NoError(t, err) + + userId2 := model.NewId() + d2 := &model.NotifyAdminData{ + UserId: userId2, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureCustomUsergroups, + } + + _, err = ss.NotifyAdmin().Save(d2) + require.NoError(t, err) + + user1Request, err := ss.NotifyAdmin().GetDataByUserIdAndFeature(userId1, model.PaidFeatureAllProfessionalfeatures) + require.NoError(t, err) + require.Equal(t, len(user1Request), 1) + require.Equal(t, user1Request[0].RequiredFeature, model.PaidFeatureAllProfessionalfeatures) + + tearDown(t, ss) +} + +func testDeleteBefore(t *testing.T, ss store.Store) { + userId1 := model.NewId() + d1 := &model.NotifyAdminData{ + UserId: userId1, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllProfessionalfeatures, + } + + _, err := ss.NotifyAdmin().Save(d1) + require.NoError(t, err) + + d1Trial := &model.NotifyAdminData{ + UserId: userId1, + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllEnterprisefeatures, + Trial: true, + } + _, err = ss.NotifyAdmin().Save(d1Trial) + require.NoError(t, err) + + d1Trial2 := &model.NotifyAdminData{ + UserId: model.NewId(), + RequiredPlan: model.LicenseShortSkuProfessional, + RequiredFeature: model.PaidFeatureAllEnterprisefeatures, + Trial: true, + } + _, err = ss.NotifyAdmin().Save(d1Trial2) + require.NoError(t, err) + + err = ss.NotifyAdmin().DeleteBefore(false, model.GetMillis()+model.GetMillis()) // delete all upgrade requests + require.NoError(t, err) + + upgradeRequests, err := ss.NotifyAdmin().Get(false) + require.NoError(t, err) + require.Equal(t, len(upgradeRequests), 0) + + trialRequests, err := ss.NotifyAdmin().Get(true) + require.NoError(t, err) + require.Equal(t, len(trialRequests), 2) // trial requests should still exist + + err = ss.NotifyAdmin().DeleteBefore(true, model.GetMillis()+model.GetMillis()) // delete all trial requests + require.NoError(t, err) + + trialRequests, err = ss.NotifyAdmin().Get(false) + require.NoError(t, err) + require.Equal(t, len(trialRequests), 0) +} diff --git a/store/storetest/store.go b/store/storetest/store.go index b58625e4d4..331e0aeb2b 100644 --- a/store/storetest/store.go +++ b/store/storetest/store.go @@ -55,6 +55,7 @@ type Store struct { SharedChannelStore mocks.SharedChannelStore ProductNoticesStore mocks.ProductNoticesStore context context.Context + NotifyAdminStore mocks.NotifyAdminStore } func (s *Store) SetContext(context context.Context) { s.context = context } @@ -95,6 +96,7 @@ func (s *Store) UserTermsOfService() store.UserTermsOfServiceStore { return &s.U func (s *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore { return &s.ChannelMemberHistoryStore } +func (s *Store) NotifyAdmin() store.NotifyAdminStore { return &s.NotifyAdminStore } 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 } @@ -154,5 +156,6 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.ThreadStore, &s.ProductNoticesStore, &s.SharedChannelStore, + &s.NotifyAdminStore, ) } diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 02993648c9..6835edbb68 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -32,6 +32,7 @@ type TimerLayer struct { JobStore store.JobStore LicenseStore store.LicenseStore LinkMetadataStore store.LinkMetadataStore + NotifyAdminStore store.NotifyAdminStore OAuthStore store.OAuthStore PluginStore store.PluginStore PostStore store.PostStore @@ -113,6 +114,10 @@ func (s *TimerLayer) LinkMetadata() store.LinkMetadataStore { return s.LinkMetadataStore } +func (s *TimerLayer) NotifyAdmin() store.NotifyAdminStore { + return s.NotifyAdminStore +} + func (s *TimerLayer) OAuth() store.OAuthStore { return s.OAuthStore } @@ -275,6 +280,11 @@ type TimerLayerLinkMetadataStore struct { Root *TimerLayer } +type TimerLayerNotifyAdminStore struct { + store.NotifyAdminStore + Root *TimerLayer +} + type TimerLayerOAuthStore struct { store.OAuthStore Root *TimerLayer @@ -4507,6 +4517,70 @@ func (s *TimerLayerLinkMetadataStore) Save(linkMetadata *model.LinkMetadata) (*m return result, err } +func (s *TimerLayerNotifyAdminStore) DeleteBefore(trial bool, now int64) error { + start := time.Now() + + err := s.NotifyAdminStore.DeleteBefore(trial, now) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("NotifyAdminStore.DeleteBefore", success, elapsed) + } + return err +} + +func (s *TimerLayerNotifyAdminStore) Get(trial bool) ([]*model.NotifyAdminData, error) { + start := time.Now() + + result, err := s.NotifyAdminStore.Get(trial) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("NotifyAdminStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerNotifyAdminStore) GetDataByUserIdAndFeature(userId string, feature model.MattermostPaidFeature) ([]*model.NotifyAdminData, error) { + start := time.Now() + + result, err := s.NotifyAdminStore.GetDataByUserIdAndFeature(userId, feature) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("NotifyAdminStore.GetDataByUserIdAndFeature", success, elapsed) + } + return result, err +} + +func (s *TimerLayerNotifyAdminStore) Save(data *model.NotifyAdminData) (*model.NotifyAdminData, error) { + start := time.Now() + + result, err := s.NotifyAdminStore.Save(data) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("NotifyAdminStore.Save", success, elapsed) + } + return result, err +} + func (s *TimerLayerOAuthStore) DeleteApp(id string) error { start := time.Now() @@ -11112,6 +11186,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.JobStore = &TimerLayerJobStore{JobStore: childStore.Job(), Root: &newStore} newStore.LicenseStore = &TimerLayerLicenseStore{LicenseStore: childStore.License(), Root: &newStore} newStore.LinkMetadataStore = &TimerLayerLinkMetadataStore{LinkMetadataStore: childStore.LinkMetadata(), Root: &newStore} + newStore.NotifyAdminStore = &TimerLayerNotifyAdminStore{NotifyAdminStore: childStore.NotifyAdmin(), Root: &newStore} newStore.OAuthStore = &TimerLayerOAuthStore{OAuthStore: childStore.OAuth(), Root: &newStore} newStore.PluginStore = &TimerLayerPluginStore{PluginStore: childStore.Plugin(), Root: &newStore} newStore.PostStore = &TimerLayerPostStore{PostStore: childStore.Post(), Root: &newStore} diff --git a/tests/test-config.json b/tests/test-config.json index 543db3950e..3604556889 100644 --- a/tests/test-config.json +++ b/tests/test-config.json @@ -60,6 +60,7 @@ "ExperimentalEnableDefaultChannelLeaveJoinMessages": true, "ExperimentalGroupUnreadChannels": "disabled", "EnableAPITeamDeletion": false, + "EnableAPITriggerAdminNotifications": false, "ExperimentalEnableHardenedMode": false }, "TeamSettings": {