diff --git a/api4/cloud.go b/api4/cloud.go index 9c23f30fca..f9d1aa3c32 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -48,6 +48,25 @@ 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.SetInvalidParam("notifyAdminRequest") + 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 48d3555478..4d77d9089d 100644 --- a/api4/cloud_test.go +++ b/api4/cloud_test.go @@ -5,9 +5,12 @@ package api4 import ( "errors" + "fmt" "net/http" "net/http/httptest" + "os" "testing" + "time" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -295,6 +298,108 @@ func Test_requestTrial(t *testing.T) { }) } +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("Initial request has invalid email", func(t *testing.T) { th := Setup(t).InitBasic() diff --git a/app/app_iface.go b/app/app_iface.go index c80b3df099..c75fad5249 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -890,6 +890,7 @@ 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.Context, channel *model.Channel, patch *model.ChannelPatch, userID string) (*model.Channel, *model.AppError) diff --git a/app/cloud.go b/app/cloud.go index 7b23f469b5..4b2fbb93d3 100644 --- a/app/cloud.go +++ b/app/cloud.go @@ -4,15 +4,108 @@ 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/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]interface{}{"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 +} + type cloudWrapper struct { cloud einterfaces.CloudInterface } diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index db76b6626b..a824fef3f1 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -12280,6 +12280,28 @@ 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") diff --git a/i18n/en.json b/i18n/en.json index 05fe271ff8..9185c4ebe3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -475,6 +475,10 @@ "id": "api.cloud.license_error", "translation": "Your license does not support cloud requests." }, + { + "id": "api.cloud.notify_admin_to_upgrade_error.already_notified", + "translation": "Already notified admin" + }, { "id": "api.cloud.request_error", "translation": "Error processing request to CWS." @@ -491,6 +495,10 @@ "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." diff --git a/model/client4.go b/model/client4.go index 5e3f61e910..8a4ac768b4 100644 --- a/model/client4.go +++ b/model/client4.go @@ -7915,6 +7915,22 @@ func (c *Client4) RequestCloudTrial(email *StartCloudTrialRequest) (*Subscriptio return subscription, BuildResponse(r), nil } +func (c *Client4) NotifyAdmin(nr *NotifyAdminToUpgradeRequest) int { + nrJSON, jsonErr := json.Marshal(nr) + if jsonErr != nil { + return 0 + } + + r, err := c.DoAPIPost(c.cloudRoute()+"/notify-admin-to-upgrade", string(nrJSON)) + if err != nil { + return r.StatusCode + } + + closeBody(r) + + return r.StatusCode +} + func (c *Client4) ValidateBusinessEmail(email *ValidateBusinessEmailRequest) (*Response, error) { payload, _ := json.Marshal(email) r, err := c.DoAPIPostBytes(c.cloudRoute()+"/validate-business-email", payload) diff --git a/model/cloud.go b/model/cloud.go index 92645a4cee..7a1a39be15 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -3,7 +3,11 @@ package model -import "strings" +import ( + "os" + "strconv" + "strings" +) const ( EventTypeFailedPayment = "failed-payment" @@ -37,6 +41,9 @@ 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"` @@ -228,3 +235,23 @@ 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/preference.go b/model/preference.go index 9f06580c10..6ce93be67a 100644 --- a/model/preference.go +++ b/model/preference.go @@ -59,6 +59,7 @@ const ( PreferenceEmailIntervalFifteenAsSeconds = "900" PreferenceEmailIntervalHour = "hour" PreferenceEmailIntervalHourAsSeconds = "3600" + PreferenceCloudUserEphemeralInfo = "cloud_user_ephemeral_info" ) type Preference struct {