diff --git a/api4/license.go b/api4/license.go index 4e20d0a67f..4e1a402ad4 100644 --- a/api4/license.go +++ b/api4/license.go @@ -26,7 +26,8 @@ func (api *API) InitLicense() { api.BaseRoutes.APIRoot.Handle("/license", api.APISessionRequired(removeLicense)).Methods("DELETE") api.BaseRoutes.APIRoot.Handle("/license/renewal", api.APISessionRequired(requestRenewalLink)).Methods("GET") api.BaseRoutes.APIRoot.Handle("/license/client", api.APIHandler(getClientLicense)).Methods("GET") - api.BaseRoutes.APIRoot.Handle("/license/review", api.APIHandler(requestTrueUpReview)).Methods("POST") + api.BaseRoutes.APIRoot.Handle("/license/review", api.APISessionRequired(requestTrueUpReview)).Methods("POST") + api.BaseRoutes.APIRoot.Handle("/license/review/status", api.APISessionRequired(trueUpReviewStatus)).Methods("GET") } func getClientLicense(c *Context, w http.ResponseWriter, r *http.Request) { @@ -309,19 +310,19 @@ func requestTrueUpReview(c *Context, w http.ResponseWriter, r *http.Request) { license := c.App.Channels().License() if license == nil { - http.Error(w, "A License is required to perform a true-up review", http.StatusBadRequest) + c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.license.required", nil, "", http.StatusNotImplemented) return } if c.App.Cloud() != nil { - c.Err = model.NewAppError("cloudTrueUpReviewNotAllowed", "app.job.true_up_review_not_allowd", nil, "", http.StatusNotImplemented) + c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.not.allowed.for.cloud", nil, "", http.StatusNotImplemented) return } // Customer Info & Usage Analytics activeUserCount, err := c.App.Srv().Store().Status().GetTotalActiveUsersCount() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.user.count.fail", nil, "", http.StatusInternalServerError) return } @@ -329,11 +330,12 @@ func requestTrueUpReview(c *Context, w http.ResponseWriter, r *http.Request) { incomingWebhookCount, err := c.App.Srv().Store().Webhook().AnalyticsIncomingCount("") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) + c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.webhook.in.count.fail", nil, "", http.StatusInternalServerError) return } outgoingWebhookCount, err := c.App.Srv().Store().Webhook().AnalyticsOutgoingCount("") if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.webhook.out.count.fail", nil, "", http.StatusInternalServerError) return } @@ -346,13 +348,13 @@ func requestTrueUpReview(c *Context, w http.ResponseWriter, r *http.Request) { if pluginResponse, err := c.App.GetPlugins(); err == nil { for _, plugin := range pluginResponse.Active { trueUpReviewPlugins.ActivePluginNames = append(trueUpReviewPlugins.ActivePluginNames, plugin.Name) - trueUpReviewPlugins.TotalActivePlugins += 1 } + trueUpReviewPlugins.TotalActivePlugins = len(trueUpReviewPlugins.ActivePluginNames) for _, plugin := range pluginResponse.Inactive { trueUpReviewPlugins.InactivePluginNames = append(trueUpReviewPlugins.InactivePluginNames, plugin.Name) - trueUpReviewPlugins.TotalInactivePlugins += 1 } + trueUpReviewPlugins.TotalInactivePlugins = len(trueUpReviewPlugins.InactivePluginNames) } // Authentication Features @@ -397,20 +399,64 @@ func requestTrueUpReview(c *Context, w http.ResponseWriter, r *http.Request) { var telemetryProperties map[string]interface{} reviewProfileJson, err := json.Marshal(reviewProfile) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + c.Err = model.NewAppError("requestTrueUpReview", "api.marshal_error", nil, "", http.StatusInternalServerError) return } - // Send telemetry data. - json.Unmarshal(reviewProfileJson, &telemetryProperties) - delete(telemetryProperties, "plugins") - plugins := reviewProfile.Plugins.ToMap() - for pluginName, pluginValue := range plugins { - telemetryProperties[pluginName] = pluginValue + dueDate := utils.GetNextTrueUpReviewDueDate() + status, err := c.App.Srv().Store().TrueUpReview().GetTrueUpReviewStatus(dueDate) + if err != nil { + c.Err = model.NewAppError("trueUpReviewStatus", "api.license.true_up_review.get.fail.app_error", nil, "", http.StatusInternalServerError) + return } - telemetryService := c.App.Srv().GetTelemetryService() - telemetryService.SendTelemetry(model.TrueUpReviewTelemetryName, telemetryProperties) + // Do not send true-up review data if the user has already requested one for the quarter. + if !status.Completed { + // Send telemetry data. + json.Unmarshal(reviewProfileJson, &telemetryProperties) + delete(telemetryProperties, "plugins") + plugins := reviewProfile.Plugins.ToMap() + for pluginName, pluginValue := range plugins { + telemetryProperties[pluginName] = pluginValue + } + + telemetryService := c.App.Srv().GetTelemetryService() + telemetryService.SendTelemetry(model.TrueUpReviewTelemetryName, telemetryProperties) + } w.Write(reviewProfileJson) } + +func trueUpReviewStatus(c *Context, w http.ResponseWriter, r *http.Request) { + // Only admins can request a true up review. + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageLicenseInformation) + return + } + + license := c.App.Channels().License() + if license == nil { + c.Err = model.NewAppError("cloudTrueUpReviewNotAllowed", "api.license.true_up_review.license.required", nil, "", http.StatusNotImplemented) + return + } + + if c.App.Cloud() != nil { + c.Err = model.NewAppError("cloudTrueUpReviewNotAllowed", "api.license.true_up_review.not.allowed.for.cloud", nil, "", http.StatusNotImplemented) + return + } + + nextDueDate := utils.GetNextTrueUpReviewDueDate() + status, err := c.App.Srv().Store().TrueUpReview().GetTrueUpReviewStatus(nextDueDate) + if err != nil { + c.Err = model.NewAppError("trueUpReviewStatus", "api.license.true_up_review.get.fail.app_error", nil, "", http.StatusInternalServerError) + return + } + + json, err := json.Marshal(status) + if err != nil { + c.Err = model.NewAppError("trueUpReviewStatus", "api.marshal_error", nil, "", http.StatusInternalServerError) + return + } + + w.Write(json) +} diff --git a/api4/license_test.go b/api4/license_test.go index 5b9ff528d8..7b3a2e9afd 100644 --- a/api4/license_test.go +++ b/api4/license_test.go @@ -367,6 +367,46 @@ func TestRequestTrueUpReview(t *testing.T) { resp, err := th.SystemAdminClient.DoAPIPost("/license/review", "") require.Error(t, err) - require.Equal(t, http.StatusBadRequest, resp.StatusCode) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + }) +} + +func TestTrueUpReviewStatus(t *testing.T) { + th := Setup(t) + + defer th.TearDown() + th.App.Srv().SetLicense(model.NewTestLicense()) + + t.Run("returns 200 when status retrieved", func(t *testing.T) { + resp, err := th.SystemAdminClient.DoAPIGet("/license/review/status", "") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("returns 501 when ran by cloud user", func(t *testing.T) { + cloud := mocks.CloudInterface{} + cloudImpl := th.App.Srv().Cloud + th.App.Srv().Cloud = &cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + + resp, err := th.SystemAdminClient.DoAPIGet("/license/review/status", "") + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + }) + + t.Run("returns 403 when user does not have permissions", func(t *testing.T) { + resp, err := th.Client.DoAPIGet("/license/review/status", "") + require.Error(t, err) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("returns 400 when license is nil", func(t *testing.T) { + th.App.Srv().SetLicense(nil) + + resp, err := th.SystemAdminClient.DoAPIGet("/license/review/status", "") + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) }) } diff --git a/db/migrations/migrations.list b/db/migrations/migrations.list index a7fffb38f3..ffaa2e283e 100644 --- a/db/migrations/migrations.list +++ b/db/migrations/migrations.list @@ -400,3 +400,5 @@ db/migrations/postgres/000099_create_drafts.down.sql db/migrations/postgres/000099_create_drafts.up.sql db/migrations/postgres/000100_add_draft_priority_column.down.sql db/migrations/postgres/000100_add_draft_priority_column.up.sql +db/migrations/postgres/000101_create_true_up_review_history.down.sql +db/migrations/postgres/000101_create_true_up_review_history.up.sql diff --git a/db/migrations/mysql/000101_create_true_up_review_history.down.sql b/db/migrations/mysql/000101_create_true_up_review_history.down.sql new file mode 100644 index 0000000000..746a779807 --- /dev/null +++ b/db/migrations/mysql/000101_create_true_up_review_history.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS trueupreviewhistory; diff --git a/db/migrations/mysql/000101_create_true_up_review_history.up.sql b/db/migrations/mysql/000101_create_true_up_review_history.up.sql new file mode 100644 index 0000000000..1ef65317ab --- /dev/null +++ b/db/migrations/mysql/000101_create_true_up_review_history.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS trueupreviewhistory ( + duedate VARCHAR(10), + completed boolean, + PRIMARY KEY (duedate) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/db/migrations/postgres/000101_create_true_up_review_history.down.sql b/db/migrations/postgres/000101_create_true_up_review_history.down.sql new file mode 100644 index 0000000000..746a779807 --- /dev/null +++ b/db/migrations/postgres/000101_create_true_up_review_history.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS trueupreviewhistory; diff --git a/db/migrations/postgres/000101_create_true_up_review_history.up.sql b/db/migrations/postgres/000101_create_true_up_review_history.up.sql new file mode 100644 index 0000000000..e42a055b92 --- /dev/null +++ b/db/migrations/postgres/000101_create_true_up_review_history.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS trueupreviewhistory ( + duedate VARCHAR(10), + completed boolean, + PRIMARY KEY (duedate) +); diff --git a/i18n/en.json b/i18n/en.json index 25f8a93b61..a57b63d641 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2077,6 +2077,30 @@ "id": "api.license.request_trial_license.fail_get_user_count.app_error", "translation": "Unable to get a trial license, please try again or contact with support@mattermost.com. Cannot obtain the number of registered users." }, + { + "id": "api.license.true_up_review.get.fail.app_error", + "translation": "Unable to get true up review records." + }, + { + "id": "api.license.true_up_review.license.required", + "translation": "A license is required to request a true up review." + }, + { + "id": "api.license.true_up_review.not.allowed.for.cloud", + "translation": "A true up review cannot be requested for cloud subscriptions." + }, + { + "id": "api.license.true_up_review.user.count.fail", + "translation": "Unable to get user counts." + }, + { + "id": "api.license.true_up_review.webhook.in.count.fail", + "translation": "Unable to get inbound webhook counts." + }, + { + "id": "api.license.true_up_review.webhook.out.count.fail", + "translation": "Unable to get outbound webhook counts." + }, { "id": "api.license.upgrade_needed.app_error", "translation": "Feature requires an upgrade to Enterprise Edition." diff --git a/model/true_up_review_profile.go b/model/true_up_review_profile.go index 8c72ffad71..f371936d39 100644 --- a/model/true_up_review_profile.go +++ b/model/true_up_review_profile.go @@ -33,3 +33,15 @@ func (t *TrueUpReviewPlugins) ToMap() map[string]any { "inactive_plugin_names": t.InactivePluginNames, } } + +type TrueUpReviewStatus struct { + Completed bool `json:"true_up_review_completed"` + DueDate string `json:"true_up_review_due_date"` +} + +func (t *TrueUpReviewStatus) ToSlice() []interface{} { + return []interface{}{ + t.DueDate, + t.Completed, + } +} diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 87b443640a..1ded368891 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -55,6 +55,7 @@ type OpenTracingLayer struct { TermsOfServiceStore store.TermsOfServiceStore ThreadStore store.ThreadStore TokenStore store.TokenStore + TrueUpReviewStore store.TrueUpReviewStore UploadSessionStore store.UploadSessionStore UserStore store.UserStore UserAccessTokenStore store.UserAccessTokenStore @@ -206,6 +207,10 @@ func (s *OpenTracingLayer) Token() store.TokenStore { return s.TokenStore } +func (s *OpenTracingLayer) TrueUpReview() store.TrueUpReviewStore { + return s.TrueUpReviewStore +} + func (s *OpenTracingLayer) UploadSession() store.UploadSessionStore { return s.UploadSessionStore } @@ -406,6 +411,11 @@ type OpenTracingLayerTokenStore struct { Root *OpenTracingLayer } +type OpenTracingLayerTrueUpReviewStore struct { + store.TrueUpReviewStore + Root *OpenTracingLayer +} + type OpenTracingLayerUploadSessionStore struct { store.UploadSessionStore Root *OpenTracingLayer @@ -10612,6 +10622,42 @@ func (s *OpenTracingLayerTokenStore) Save(recovery *model.Token) error { return err } +func (s *OpenTracingLayerTrueUpReviewStore) CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "TrueUpReviewStore.CreateTrueUpReviewStatusRecord") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.TrueUpReviewStore.CreateTrueUpReviewStatusRecord(reviewStatus) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerTrueUpReviewStore) GetTrueUpReviewStatus(dueDate string) (*model.TrueUpReviewStatus, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "TrueUpReviewStore.GetTrueUpReviewStatus") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.TrueUpReviewStore.GetTrueUpReviewStatus(dueDate) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerUploadSessionStore) Delete(id string) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UploadSessionStore.Delete") @@ -12840,6 +12886,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.TermsOfServiceStore = &OpenTracingLayerTermsOfServiceStore{TermsOfServiceStore: childStore.TermsOfService(), Root: &newStore} newStore.ThreadStore = &OpenTracingLayerThreadStore{ThreadStore: childStore.Thread(), Root: &newStore} newStore.TokenStore = &OpenTracingLayerTokenStore{TokenStore: childStore.Token(), Root: &newStore} + newStore.TrueUpReviewStore = &OpenTracingLayerTrueUpReviewStore{TrueUpReviewStore: childStore.TrueUpReview(), Root: &newStore} newStore.UploadSessionStore = &OpenTracingLayerUploadSessionStore{UploadSessionStore: childStore.UploadSession(), Root: &newStore} newStore.UserStore = &OpenTracingLayerUserStore{UserStore: childStore.User(), Root: &newStore} newStore.UserAccessTokenStore = &OpenTracingLayerUserAccessTokenStore{UserAccessTokenStore: childStore.UserAccessToken(), Root: &newStore} diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 38a3f52dbf..b9f1c9f8aa 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -58,6 +58,7 @@ type RetryLayer struct { TermsOfServiceStore store.TermsOfServiceStore ThreadStore store.ThreadStore TokenStore store.TokenStore + TrueUpReviewStore store.TrueUpReviewStore UploadSessionStore store.UploadSessionStore UserStore store.UserStore UserAccessTokenStore store.UserAccessTokenStore @@ -209,6 +210,10 @@ func (s *RetryLayer) Token() store.TokenStore { return s.TokenStore } +func (s *RetryLayer) TrueUpReview() store.TrueUpReviewStore { + return s.TrueUpReviewStore +} + func (s *RetryLayer) UploadSession() store.UploadSessionStore { return s.UploadSessionStore } @@ -409,6 +414,11 @@ type RetryLayerTokenStore struct { Root *RetryLayer } +type RetryLayerTrueUpReviewStore struct { + store.TrueUpReviewStore + Root *RetryLayer +} + type RetryLayerUploadSessionStore struct { store.UploadSessionStore Root *RetryLayer @@ -12126,6 +12136,48 @@ func (s *RetryLayerTokenStore) Save(recovery *model.Token) error { } +func (s *RetryLayerTrueUpReviewStore) CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) { + + tries := 0 + for { + result, err := s.TrueUpReviewStore.CreateTrueUpReviewStatusRecord(reviewStatus) + 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 *RetryLayerTrueUpReviewStore) GetTrueUpReviewStatus(dueDate string) (*model.TrueUpReviewStatus, error) { + + tries := 0 + for { + result, err := s.TrueUpReviewStore.GetTrueUpReviewStatus(dueDate) + 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 *RetryLayerUploadSessionStore) Delete(id string) error { tries := 0 @@ -14630,6 +14682,7 @@ func New(childStore store.Store) *RetryLayer { newStore.TermsOfServiceStore = &RetryLayerTermsOfServiceStore{TermsOfServiceStore: childStore.TermsOfService(), Root: &newStore} newStore.ThreadStore = &RetryLayerThreadStore{ThreadStore: childStore.Thread(), Root: &newStore} newStore.TokenStore = &RetryLayerTokenStore{TokenStore: childStore.Token(), Root: &newStore} + newStore.TrueUpReviewStore = &RetryLayerTrueUpReviewStore{TrueUpReviewStore: childStore.TrueUpReview(), Root: &newStore} newStore.UploadSessionStore = &RetryLayerUploadSessionStore{UploadSessionStore: childStore.UploadSession(), Root: &newStore} newStore.UserStore = &RetryLayerUserStore{UserStore: childStore.User(), Root: &newStore} newStore.UserAccessTokenStore = &RetryLayerUserAccessTokenStore{UserAccessTokenStore: childStore.UserAccessToken(), Root: &newStore} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 4c1bf1f05b..bf32b5984b 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -112,6 +112,7 @@ type SqlStoreStores struct { notifyAdmin store.NotifyAdminStore postPriority store.PostPriorityStore postAcknowledgement store.PostAcknowledgementStore + trueUpReviewStatus store.TrueUpReviewStore } type SqlStore struct { @@ -220,6 +221,7 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS store.stores.notifyAdmin = newSqlNotifyAdminStore(store) store.stores.postPriority = newSqlPostPriorityStore(store) store.stores.postAcknowledgement = newSqlPostAcknowledgementStore(store) + store.stores.trueUpReviewStatus = newSqlTrueUpReviewStore(store) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -973,6 +975,10 @@ func (ss *SqlStore) PostAcknowledgement() store.PostAcknowledgementStore { return ss.stores.postAcknowledgement } +func (ss *SqlStore) TrueUpReview() store.TrueUpReviewStore { + return ss.stores.trueUpReviewStatus +} + func (ss *SqlStore) DropAllTables() { if ss.DriverName() == model.DatabaseDriverPostgres { ss.masterX.Exec(`DO diff --git a/store/sqlstore/true_up_review_store.go b/store/sqlstore/true_up_review_store.go new file mode 100644 index 0000000000..87fd29d93f --- /dev/null +++ b/store/sqlstore/true_up_review_store.go @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" +) + +// SqlLicenseStore encapsulates the database writes and reads for +// model.LicenseRecord objects. +type SqlTrueUpReviewStore struct { + *SqlStore +} + +func newSqlTrueUpReviewStore(sqlStore *SqlStore) store.TrueUpReviewStore { + return &SqlTrueUpReviewStore{sqlStore} +} + +func trueUpReviewStatusColumns() []string { + return []string{ + "DueDate", + "Completed", + } +} + +func (s *SqlTrueUpReviewStore) GetTrueUpReviewStatus(dueDate string) (*model.TrueUpReviewStatus, error) { + query := s.getQueryBuilder(). + Select("*"). + From("TrueUpReviewHistory"). + Where(sq.Eq{"DueDate": dueDate}) + + queryString, args, err := query.ToSql() + if err != nil { + return nil, errors.Wrap(err, "get_trueUpReviewStatusRecord_tosql") + } + var trueUpReviewStatus model.TrueUpReviewStatus + if err := s.GetReplicaX().Get(&trueUpReviewStatus, queryString, args...); err != nil { + trueUpReviewStatus.Completed = false + trueUpReviewStatus.DueDate = dueDate + + // If no record is available, create one so there is a record trail. + return s.CreateTrueUpReviewStatusRecord(&trueUpReviewStatus) + } + + return &trueUpReviewStatus, nil +} + +func (s *SqlTrueUpReviewStore) CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) { + builder := s.getQueryBuilder().Insert("TrueUpReviewHistory").Columns(trueUpReviewStatusColumns()...).Values(reviewStatus.ToSlice()...) + query, args, err := builder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "create_trueUpReviewStatusRecord_tosql") + } + + if _, err = s.GetMasterX().Exec(query, args...); err != nil { + return nil, errors.Wrap(err, "fail to create true up review status record") + } + + return reviewStatus, nil +} + +func (s *SqlTrueUpReviewStore) Update(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) { + query := s.getQueryBuilder(). + Update("TrueUpReviewHistory"). + Set("Completed", reviewStatus.Completed). + Where(sq.Eq{"DueDate": reviewStatus.DueDate}) + + if _, err := s.GetMasterX().ExecBuilder(query); err != nil { + return nil, errors.Wrapf(err, "failed to update true up review status with DueDate=%s", reviewStatus.DueDate) + } + + return reviewStatus, nil +} diff --git a/store/store.go b/store/store.go index 49490afdbb..346bfee7ca 100644 --- a/store/store.go +++ b/store/store.go @@ -87,6 +87,7 @@ type Store interface { NotifyAdmin() NotifyAdminStore PostPriority() PostPriorityStore PostAcknowledgement() PostAcknowledgementStore + TrueUpReview() TrueUpReviewStore } type RetentionPolicyStore interface { @@ -998,6 +999,11 @@ type PostAcknowledgementStore interface { Delete(acknowledgement *model.PostAcknowledgement) error } +type TrueUpReviewStore interface { + GetTrueUpReviewStatus(dueDate string) (*model.TrueUpReviewStatus, error) + CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) +} + // ChannelSearchOpts contains options for searching channels. // // NotAssociatedToGroup will exclude channels that have associated, active GroupChannels records. diff --git a/store/storetest/mocks/Store.go b/store/storetest/mocks/Store.go index 395cc3e9f1..fc22cd3545 100644 --- a/store/storetest/mocks/Store.go +++ b/store/storetest/mocks/Store.go @@ -843,6 +843,22 @@ func (_m *Store) TotalSearchDbConnections() int { return r0 } +// TrueUpReview provides a mock function with given fields: +func (_m *Store) TrueUpReview() store.TrueUpReviewStore { + ret := _m.Called() + + var r0 store.TrueUpReviewStore + if rf, ok := ret.Get(0).(func() store.TrueUpReviewStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.TrueUpReviewStore) + } + } + + return r0 +} + // UnlockFromMaster provides a mock function with given fields: func (_m *Store) UnlockFromMaster() { _m.Called() diff --git a/store/storetest/mocks/TrueUpReviewStore.go b/store/storetest/mocks/TrueUpReviewStore.go new file mode 100644 index 0000000000..fcffe843f3 --- /dev/null +++ b/store/storetest/mocks/TrueUpReviewStore.go @@ -0,0 +1,61 @@ +// 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" +) + +// TrueUpReviewStore is an autogenerated mock type for the TrueUpReviewStore type +type TrueUpReviewStore struct { + mock.Mock +} + +// CreateTrueUpReviewStatusRecord provides a mock function with given fields: reviewStatus +func (_m *TrueUpReviewStore) CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) { + ret := _m.Called(reviewStatus) + + var r0 *model.TrueUpReviewStatus + if rf, ok := ret.Get(0).(func(*model.TrueUpReviewStatus) *model.TrueUpReviewStatus); ok { + r0 = rf(reviewStatus) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.TrueUpReviewStatus) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.TrueUpReviewStatus) error); ok { + r1 = rf(reviewStatus) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetTrueUpReviewStatus provides a mock function with given fields: dueDate +func (_m *TrueUpReviewStore) GetTrueUpReviewStatus(dueDate string) (*model.TrueUpReviewStatus, error) { + ret := _m.Called(dueDate) + + var r0 *model.TrueUpReviewStatus + if rf, ok := ret.Get(0).(func(string) *model.TrueUpReviewStatus); ok { + r0 = rf(dueDate) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.TrueUpReviewStatus) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(dueDate) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/store/storetest/store.go b/store/storetest/store.go index e873ae4994..9e07f2217f 100644 --- a/store/storetest/store.go +++ b/store/storetest/store.go @@ -59,6 +59,7 @@ type Store struct { NotifyAdminStore mocks.NotifyAdminStore PostPriorityStore mocks.PostPriorityStore PostAcknowledgementStore mocks.PostAcknowledgementStore + TrueUpReviewStore mocks.TrueUpReviewStore } func (s *Store) SetContext(context context.Context) { s.context = context } @@ -100,6 +101,9 @@ func (s *Store) Draft() store.DraftStore { return &s.D func (s *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore { return &s.ChannelMemberHistoryStore } +func (s *Store) TrueUpReview() store.TrueUpReviewStore { + return &s.TrueUpReviewStore +} 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 } diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 02be7b110d..e08402764f 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -54,6 +54,7 @@ type TimerLayer struct { TermsOfServiceStore store.TermsOfServiceStore ThreadStore store.ThreadStore TokenStore store.TokenStore + TrueUpReviewStore store.TrueUpReviewStore UploadSessionStore store.UploadSessionStore UserStore store.UserStore UserAccessTokenStore store.UserAccessTokenStore @@ -205,6 +206,10 @@ func (s *TimerLayer) Token() store.TokenStore { return s.TokenStore } +func (s *TimerLayer) TrueUpReview() store.TrueUpReviewStore { + return s.TrueUpReviewStore +} + func (s *TimerLayer) UploadSession() store.UploadSessionStore { return s.UploadSessionStore } @@ -405,6 +410,11 @@ type TimerLayerTokenStore struct { Root *TimerLayer } +type TimerLayerTrueUpReviewStore struct { + store.TrueUpReviewStore + Root *TimerLayer +} + type TimerLayerUploadSessionStore struct { store.UploadSessionStore Root *TimerLayer @@ -9549,6 +9559,38 @@ func (s *TimerLayerTokenStore) Save(recovery *model.Token) error { return err } +func (s *TimerLayerTrueUpReviewStore) CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) { + start := time.Now() + + result, err := s.TrueUpReviewStore.CreateTrueUpReviewStatusRecord(reviewStatus) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("TrueUpReviewStore.CreateTrueUpReviewStatusRecord", success, elapsed) + } + return result, err +} + +func (s *TimerLayerTrueUpReviewStore) GetTrueUpReviewStatus(dueDate string) (*model.TrueUpReviewStatus, error) { + start := time.Now() + + result, err := s.TrueUpReviewStore.GetTrueUpReviewStatus(dueDate) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("TrueUpReviewStore.GetTrueUpReviewStatus", success, elapsed) + } + return result, err +} + func (s *TimerLayerUploadSessionStore) Delete(id string) error { start := time.Now() @@ -11573,6 +11615,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.TermsOfServiceStore = &TimerLayerTermsOfServiceStore{TermsOfServiceStore: childStore.TermsOfService(), Root: &newStore} newStore.ThreadStore = &TimerLayerThreadStore{ThreadStore: childStore.Thread(), Root: &newStore} newStore.TokenStore = &TimerLayerTokenStore{TokenStore: childStore.Token(), Root: &newStore} + newStore.TrueUpReviewStore = &TimerLayerTrueUpReviewStore{TrueUpReviewStore: childStore.TrueUpReview(), Root: &newStore} newStore.UploadSessionStore = &TimerLayerUploadSessionStore{UploadSessionStore: childStore.UploadSession(), Root: &newStore} newStore.UserStore = &TimerLayerUserStore{UserStore: childStore.User(), Root: &newStore} newStore.UserAccessTokenStore = &TimerLayerUserAccessTokenStore{UserAccessTokenStore: childStore.UserAccessToken(), Root: &newStore} diff --git a/utils/license.go b/utils/license.go index 0c6b7dbe04..3d2c242d81 100644 --- a/utils/license.go +++ b/utils/license.go @@ -16,6 +16,7 @@ import ( "os" "path/filepath" "strconv" + "time" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/mlog" @@ -34,6 +35,9 @@ hwIDAQAB var LicenseValidator LicenseValidatorIface +const TrueUpReviewDueDay = 15 +const BusinessQuaterStep = 3 + func init() { if LicenseValidator == nil { LicenseValidator = &LicenseValidatorImpl{} @@ -224,3 +228,21 @@ func GetSanitizedClientLicense(l map[string]string) map[string]string { return sanitizedLicense } + +func GetNextTrueUpReviewDueDate() string { + now := time.Now().UTC() + quaterEndMonths := []time.Month{time.March, time.June, time.September, time.December} + + var nextQuaterEndMonth time.Month = time.March + for _, month := range quaterEndMonths { + if now.Month() <= month && now.Day() <= TrueUpReviewDueDay { + nextQuaterEndMonth = month + break + } else if now.Month() <= month && now.Day() > TrueUpReviewDueDay { + nextQuaterEndMonth = month + BusinessQuaterStep + break + } + } + + return time.Date(now.Year(), nextQuaterEndMonth, TrueUpReviewDueDay, 0, 0, 0, 0, now.Location()).Format("2006-01-02") +}