From 6f87eb67fcdd93bd2e5d125d130058a70937a0fc Mon Sep 17 00:00:00 2001 From: Nathaniel Allred Date: Mon, 9 May 2022 08:05:50 -0500 Subject: [PATCH] MM-43084 update product limits through webhook and websocket (#20076) * Define interfaces for updating subscriptions and product limits from CWS webhook. --- api4/cloud.go | 16 +++++++ app/plugin_api.go | 6 +++ app/plugin_hooks_test.go | 44 +++++++++++++++++++ einterfaces/cloud.go | 1 + einterfaces/mocks/CloudInterface.go | 14 ++++++ i18n/en.json | 4 ++ model/cloud.go | 3 ++ model/websocket_message.go | 1 + plugin/api.go | 5 +++ plugin/api_timer_layer_generated.go | 7 +++ plugin/client_rpc_generated.go | 62 +++++++++++++++++++++++++++ plugin/hooks.go | 6 +++ plugin/hooks_timer_layer_generated.go | 6 +++ plugin/plugintest/api.go | 23 ++++++++++ plugin/plugintest/hooks.go | 5 +++ 15 files changed, 203 insertions(+) diff --git a/api4/cloud.go b/api4/cloud.go index 3827597c82..10b5d3e434 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -14,6 +14,7 @@ import ( "github.com/mattermost/mattermost-server/v6/audit" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/plugin" ) func (api *API) InitCloud() { @@ -486,6 +487,21 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = appErr return } + case model.EventTypeSubscriptionChanged: + // event.ProductLimits is nil if there was no change + if event.ProductLimits != nil { + if pluginsEnvironment := c.App.GetPluginsEnvironment(); pluginsEnvironment != nil { + pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool { + hooks.OnCloudLimitsUpdated(event.ProductLimits) + return true + }, plugin.OnCloudLimitsUpdatedID) + } + } + if err := c.App.Cloud().UpdateSubscriptionFromHook(event.ProductLimits, event.Subscription); err != nil { + c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.subscription.update_error", nil, err.Error(), http.StatusInternalServerError) + return + } + c.Logger.Info("Updated subscription from webhook event") default: c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.cws_webhook_event_missing_error", nil, "", http.StatusNotFound) diff --git a/app/plugin_api.go b/app/plugin_api.go index ced18804a2..f14b6f2b81 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -1179,3 +1179,9 @@ func (api *PluginAPI) RequestTrialLicense(requesterID string, users int, termsAc return api.app.Channels().RequestTrialLicense(requesterID, users, termsAccepted, receiveEmailsAccepted) } + +// GetCloudLimits returns any limits associated with the cloud instance +func (api *PluginAPI) GetCloudLimits() (*model.ProductLimits, error) { + limits, err := api.app.Cloud().GetCloudLimits("") + return limits, err +} diff --git a/app/plugin_hooks_test.go b/app/plugin_hooks_test.go index 5d8cbcb5ff..84edff0335 100644 --- a/app/plugin_hooks_test.go +++ b/app/plugin_hooks_test.go @@ -1302,3 +1302,47 @@ func TestHookOnSendDailyTelemetry(t *testing.T) { require.True(t, hookCalled) } + +func TestHookOnCloudLimitsUpdated(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + tearDown, pluginIDs, _ := SetAppEnvironmentWithPlugins(t, + []string{ + ` + package main + + import ( + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/plugin" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) OnCloudLimitsUpdated(_ *model.ProductLimits) { + return + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + `}, th.App, th.NewPluginAPI) + defer tearDown() + + require.Len(t, pluginIDs, 1) + pluginID := pluginIDs[0] + + require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID)) + + hookCalled := false + th.App.GetPluginsEnvironment().RunMultiPluginHook(func(hooks plugin.Hooks) bool { + hooks.OnCloudLimitsUpdated(nil) + + hookCalled = true + return hookCalled + }, plugin.OnCloudLimitsUpdatedID) + + require.True(t, hookCalled) +} diff --git a/einterfaces/cloud.go b/einterfaces/cloud.go index 48c43e6f43..750023965f 100644 --- a/einterfaces/cloud.go +++ b/einterfaces/cloud.go @@ -10,6 +10,7 @@ import ( type CloudInterface interface { GetCloudProducts(userID string, includeLegacyProducts bool) ([]*model.Product, error) GetCloudLimits(userID string) (*model.ProductLimits, error) + UpdateSubscriptionFromHook(*model.ProductLimits, *model.Subscription) error CreateCustomerPayment(userID string) (*model.StripeSetupIntent, error) ConfirmCustomerPayment(userID string, confirmRequest *model.ConfirmPaymentMethodRequest) error diff --git a/einterfaces/mocks/CloudInterface.go b/einterfaces/mocks/CloudInterface.go index 64b33b3dbb..8ab5cf0c8f 100644 --- a/einterfaces/mocks/CloudInterface.go +++ b/einterfaces/mocks/CloudInterface.go @@ -292,3 +292,17 @@ func (_m *CloudInterface) UpdateCloudCustomerAddress(userID string, address *mod return r0, r1 } + +// UpdateSubscriptionFromHook provides a mock function with given fields: _a0, _a1 +func (_m *CloudInterface) UpdateSubscriptionFromHook(_a0 *model.ProductLimits, _a1 *model.Subscription) error { + ret := _m.Called(_a0, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(*model.ProductLimits, *model.Subscription) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/i18n/en.json b/i18n/en.json index ce138a44c2..d6a0c783c5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -475,6 +475,10 @@ "id": "api.cloud.request_error", "translation": "Error processing request to CWS." }, + { + "id": "api.cloud.subscription.update_error", + "translation": "Error updating subscription from webhook." + }, { "id": "api.command.admin_only.app_error", "translation": "Integrations have been limited to admins only." diff --git a/model/cloud.go b/model/cloud.go index 0fcfc6aecd..24ca1fb029 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -10,6 +10,7 @@ const ( EventTypeFailedPaymentNoCard = "failed-payment-no-card" EventTypeSendAdminWelcomeEmail = "send-admin-welcome-email" EventTypeSendUpgradeConfirmationEmail = "send-upgrade-confirmation-email" + EventTypeSubscriptionChanged = "subscription-changed" EventTypeTrialWillEnd = "trial-will-end" EventTypeTrialEnded = "trial-ended" ) @@ -166,6 +167,8 @@ type CWSWebhookPayload struct { Event string `json:"event"` FailedPayment *FailedPayment `json:"failed_payment"` CloudWorkspaceOwner *CloudWorkspaceOwner `json:"cloud_workspace_owner"` + ProductLimits *ProductLimits `json:"product_limits"` + Subscription *Subscription `json:"subscription"` SubscriptionTrialEndUnixTimeStamp int64 `json:"trial_end_time_stamp"` } diff --git a/model/websocket_message.go b/model/websocket_message.go index 343d73050c..baff25456b 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -71,6 +71,7 @@ const ( WebsocketWarnMetricStatusReceived = "warn_metric_status_received" WebsocketWarnMetricStatusRemoved = "warn_metric_status_removed" WebsocketEventCloudPaymentStatusUpdated = "cloud_payment_status_updated" + WebsocketEventCloudSubscriptionChanged = "cloud_subscription_changed" WebsocketEventThreadUpdated = "thread_updated" WebsocketEventThreadFollowChanged = "thread_follow_changed" WebsocketEventThreadReadChanged = "thread_read_changed" diff --git a/plugin/api.go b/plugin/api.go index 2a9ccf18be..c2e43e4a61 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -1147,6 +1147,11 @@ type API interface { // // Minimum server version: 5.36 RequestTrialLicense(requesterID string, users int, termsAccepted bool, receiveEmailsAccepted bool) *model.AppError + + // GetCloudLimits gets limits associated with a cloud workspace, if any + // + // Minimum server version: 7.0 + GetCloudLimits() (*model.ProductLimits, error) } var handshake = plugin.HandshakeConfig{ diff --git a/plugin/api_timer_layer_generated.go b/plugin/api_timer_layer_generated.go index d271797907..a6d300be20 100644 --- a/plugin/api_timer_layer_generated.go +++ b/plugin/api_timer_layer_generated.go @@ -1225,3 +1225,10 @@ func (api *apiTimerLayer) RequestTrialLicense(requesterID string, users int, ter api.recordTime(startTime, "RequestTrialLicense", _returnsA == nil) return _returnsA } + +func (api *apiTimerLayer) GetCloudLimits() (*model.ProductLimits, error) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.GetCloudLimits() + api.recordTime(startTime, "GetCloudLimits", _returnsB == nil) + return _returnsA, _returnsB +} diff --git a/plugin/client_rpc_generated.go b/plugin/client_rpc_generated.go index 72b3838b18..3cef284744 100644 --- a/plugin/client_rpc_generated.go +++ b/plugin/client_rpc_generated.go @@ -774,6 +774,39 @@ func (s *hooksRPCServer) OnSendDailyTelemetry(args *Z_OnSendDailyTelemetryArgs, return nil } +func init() { + hookNameToId["OnCloudLimitsUpdated"] = OnCloudLimitsUpdatedID +} + +type Z_OnCloudLimitsUpdatedArgs struct { + A *model.ProductLimits +} + +type Z_OnCloudLimitsUpdatedReturns struct { +} + +func (g *hooksRPCClient) OnCloudLimitsUpdated(limits *model.ProductLimits) { + _args := &Z_OnCloudLimitsUpdatedArgs{limits} + _returns := &Z_OnCloudLimitsUpdatedReturns{} + if g.implemented[OnCloudLimitsUpdatedID] { + if err := g.client.Call("Plugin.OnCloudLimitsUpdated", _args, _returns); err != nil { + g.log.Error("RPC call OnCloudLimitsUpdated to plugin failed.", mlog.Err(err)) + } + } + +} + +func (s *hooksRPCServer) OnCloudLimitsUpdated(args *Z_OnCloudLimitsUpdatedArgs, returns *Z_OnCloudLimitsUpdatedReturns) error { + if hook, ok := s.impl.(interface { + OnCloudLimitsUpdated(limits *model.ProductLimits) + }); ok { + hook.OnCloudLimitsUpdated(args.A) + } else { + return encodableError(fmt.Errorf("Hook OnCloudLimitsUpdated called but not implemented.")) + } + return nil +} + type Z_RegisterCommandArgs struct { A *model.Command } @@ -5616,3 +5649,32 @@ func (s *apiRPCServer) RequestTrialLicense(args *Z_RequestTrialLicenseArgs, retu } return nil } + +type Z_GetCloudLimitsArgs struct { +} + +type Z_GetCloudLimitsReturns struct { + A *model.ProductLimits + B error +} + +func (g *apiRPCClient) GetCloudLimits() (*model.ProductLimits, error) { + _args := &Z_GetCloudLimitsArgs{} + _returns := &Z_GetCloudLimitsReturns{} + if err := g.client.Call("Plugin.GetCloudLimits", _args, _returns); err != nil { + log.Printf("RPC call to GetCloudLimits API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) GetCloudLimits(args *Z_GetCloudLimitsArgs, returns *Z_GetCloudLimitsReturns) error { + if hook, ok := s.impl.(interface { + GetCloudLimits() (*model.ProductLimits, error) + }); ok { + returns.A, returns.B = hook.GetCloudLimits() + returns.B = encodableError(returns.B) + } else { + return encodableError(fmt.Errorf("API GetCloudLimits called but not implemented.")) + } + return nil +} diff --git a/plugin/hooks.go b/plugin/hooks.go index a8d6485c9e..0cf4e3c07a 100644 --- a/plugin/hooks.go +++ b/plugin/hooks.go @@ -42,6 +42,7 @@ const ( RunDataRetentionID = 24 OnInstallID = 25 OnSendDailyTelemetryID = 26 + OnCloudLimitsUpdatedID = 27 TotalHooksID = iota ) @@ -264,4 +265,9 @@ type Hooks interface { // // Minimum server version: 6.5 OnSendDailyTelemetry() + + // OnCloudLimitsUpdated is invoked product limits change, for example when plan tiers change + // + // Minimum server version: 7.0 + OnCloudLimitsUpdated(limits *model.ProductLimits) } diff --git a/plugin/hooks_timer_layer_generated.go b/plugin/hooks_timer_layer_generated.go index 527862e8f5..92ca2e393d 100644 --- a/plugin/hooks_timer_layer_generated.go +++ b/plugin/hooks_timer_layer_generated.go @@ -206,3 +206,9 @@ func (hooks *hooksTimerLayer) OnSendDailyTelemetry() { hooks.hooksImpl.OnSendDailyTelemetry() hooks.recordTime(startTime, "OnSendDailyTelemetry", true) } + +func (hooks *hooksTimerLayer) OnCloudLimitsUpdated(limits *model.ProductLimits) { + startTime := timePkg.Now() + hooks.hooksImpl.OnCloudLimitsUpdated(limits) + hooks.recordTime(startTime, "OnCloudLimitsUpdated", true) +} diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index 39d6ad8a89..a19076750e 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -980,6 +980,29 @@ func (_m *API) GetChannelsForTeamForUser(teamID string, userID string, includeDe return r0, r1 } +// GetCloudLimits provides a mock function with given fields: +func (_m *API) GetCloudLimits() (*model.ProductLimits, error) { + ret := _m.Called() + + var r0 *model.ProductLimits + if rf, ok := ret.Get(0).(func() *model.ProductLimits); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ProductLimits) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetCommand provides a mock function with given fields: commandID func (_m *API) GetCommand(commandID string) (*model.Command, error) { ret := _m.Called(commandID) diff --git a/plugin/plugintest/hooks.go b/plugin/plugintest/hooks.go index 9d8a4a3a28..c02092a9aa 100644 --- a/plugin/plugintest/hooks.go +++ b/plugin/plugintest/hooks.go @@ -166,6 +166,11 @@ func (_m *Hooks) OnActivate() error { return r0 } +// OnCloudLimitsUpdated provides a mock function with given fields: limits +func (_m *Hooks) OnCloudLimitsUpdated(limits *model.ProductLimits) { + _m.Called(limits) +} + // OnConfigurationChange provides a mock function with given fields: func (_m *Hooks) OnConfigurationChange() error { ret := _m.Called()