diff --git a/api4/cloud.go b/api4/cloud.go index d517291d48..e9493a32d4 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -52,6 +52,9 @@ func (api *API) InitCloud() { // POST /api/v4/cloud/webhook api.BaseRoutes.Cloud.Handle("/webhook", api.CloudAPIKeyRequired(handleCWSWebhook)).Methods("POST") + + // GET /api/v4/cloud/cws-health-check + api.BaseRoutes.Cloud.Handle("/check-cws-connection", api.APIHandler(handleCheckCWSConnection)).Methods("GET") } func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) { @@ -746,3 +749,12 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) { ReturnStatusOK(w) } + +func handleCheckCWSConnection(c *Context, w http.ResponseWriter, r *http.Request) { + if err := c.App.Cloud().CheckCWSConnection(c.AppContext.Session().UserId); err != nil { + c.Err = model.NewAppError("Api4.handleCWSHealthCheck", "api.server.cws.health_check.app_error", nil, "CWS Server is not available.", http.StatusInternalServerError) + return + } + + ReturnStatusOK(w) +} diff --git a/api4/license.go b/api4/license.go index bc89f7e1e2..69537ba9e5 100644 --- a/api4/license.go +++ b/api4/license.go @@ -5,6 +5,7 @@ package api4 import ( "bytes" + b64 "encoding/base64" "encoding/json" "fmt" "io" @@ -24,6 +25,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.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) { @@ -296,3 +299,100 @@ func getPrevTrialLicense(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJSON(clientLicense))) } + +func requestTrueUpReview(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("requestTrueUpReview", "api.license.true_up_review.license_required", nil, "", http.StatusNotImplemented) + return + } + + if license.IsCloud() { + c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.not_allowed_for_cloud", nil, "", http.StatusNotImplemented) + return + } + + status, appErr := c.App.GetOrCreateTrueUpReviewStatus() + if appErr != nil { + c.Err = appErr + return + } + + // If a true up review has already been submitted for the current due date, complete the request + // with no errors. + if status.Completed { + ReturnStatusOK(w) + } + + profileMap, err := c.App.GetTrueUpProfile() + if err != nil { + c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.get_status_error", nil, "", http.StatusInternalServerError) + return + } + + profileMapJson, err := json.Marshal(profileMap) + if err != nil { + c.SetJSONEncodingError(err) + return + } + + // Do not send true-up review data if the user has already requested one for the quarter. + // And only send a true-up review via as a one-time telemetry request if telemetry is disabled. + telemetryEnabled := c.App.Config().LogSettings.EnableDiagnostics + if telemetryEnabled != nil && !*telemetryEnabled { + // Send telemetry data + c.App.Srv().GetTelemetryService().SendTelemetry(model.TrueUpReviewTelemetryName, profileMap) + + // Update the review status to reflect the completion. + status.Completed = true + c.App.Srv().Store().TrueUpReview().Update(status) + } + + // Encode to string rather than byte[] otherwise json.Marshal will encode it further. + encodedData := b64.StdEncoding.EncodeToString(profileMapJson) + responseContent := struct { + Content string `json:"content"` + }{Content: encodedData} + response, _ := json.Marshal(responseContent) + + w.Write(response) +} + +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 + } + + // Check for license + license := c.App.Channels().License() + if license == nil { + c.Err = model.NewAppError("cloudTrueUpReviewNotAllowed", "api.license.true_up_review.license_required", nil, "True up review requires a license", http.StatusNotImplemented) + return + } + + if license.IsCloud() { + c.Err = model.NewAppError("cloudTrueUpReviewNotAllowed", "api.license.true_up_review.not_allowed_for_cloud", nil, "True up review is not allowed for cloud instances", http.StatusNotImplemented) + return + } + + status, appErr := c.App.GetOrCreateTrueUpReviewStatus() + if appErr != nil { + c.Err = appErr + } + + 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 fe4d62848e..dec711dfc1 100644 --- a/api4/license_test.go +++ b/api4/license_test.go @@ -328,3 +328,77 @@ func TestRequestRenewalLink(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) } + +func TestRequestTrueUpReview(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense()) + + t.Run("returns status 200 when telemetry data sent", func(t *testing.T) { + resp, err := th.SystemAdminClient.DoAPIPost("/license/review", "") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("returns 501 when ran by cloud user", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + resp, err := th.SystemAdminClient.DoAPIPost("/license/review", "") + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + + th.App.Srv().SetLicense(model.NewTestLicense()) + }) + + t.Run("returns 403 when user does not have permissions", func(t *testing.T) { + resp, err := th.Client.DoAPIPost("/license/review", "") + 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.DoAPIPost("/license/review", "") + require.Error(t, err) + 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) { + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + resp, err := th.SystemAdminClient.DoAPIGet("/license/review/status", "") + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + + th.App.Srv().SetLicense(model.NewTestLicense()) + }) + + 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/app/app_iface.go b/app/app_iface.go index 8c10cb16fc..b6af1556a0 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -699,6 +699,7 @@ type AppIface interface { GetOnboarding() (*model.System, *model.AppError) GetOpenGraphMetadata(requestURL string) ([]byte, error) GetOrCreateDirectChannel(c request.CTX, userID, otherUserID string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError) + GetOrCreateTrueUpReviewStatus() (*model.TrueUpReviewStatus, *model.AppError) GetOutgoingWebhook(hookID string) (*model.OutgoingWebhook, *model.AppError) GetOutgoingWebhooksForChannelPageByUser(channelID string, userID string, page, perPage int) ([]*model.OutgoingWebhook, *model.AppError) GetOutgoingWebhooksForTeamPage(teamID string, page, perPage int) ([]*model.OutgoingWebhook, *model.AppError) @@ -811,6 +812,7 @@ type AppIface interface { GetTopReactionsForUserSince(userID string, teamID string, opts *model.InsightsOpts) (*model.TopReactionList, *model.AppError) GetTopThreadsForTeamSince(c request.CTX, teamID, userID string, opts *model.InsightsOpts) (*model.TopThreadList, *model.AppError) GetTopThreadsForUserSince(c request.CTX, teamID, userID string, opts *model.InsightsOpts) (*model.TopThreadList, *model.AppError) + GetTrueUpProfile() (map[string]any, error) GetUploadSession(c request.CTX, uploadId string) (*model.UploadSession, *model.AppError) GetUploadSessionsForUser(userID string) ([]*model.UploadSession, *model.AppError) GetUser(userID string) (*model.User, *model.AppError) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 608d007d7d..9f64d4e6ce 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -7584,6 +7584,28 @@ func (a *OpenTracingAppLayer) GetOrCreateDirectChannel(c request.CTX, userID str return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetOrCreateTrueUpReviewStatus() (*model.TrueUpReviewStatus, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetOrCreateTrueUpReviewStatus") + + 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.GetOrCreateTrueUpReviewStatus() + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetOutgoingWebhook(hookID string) (*model.OutgoingWebhook, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetOutgoingWebhook") @@ -10328,6 +10350,28 @@ func (a *OpenTracingAppLayer) GetTotalUsersStats(viewRestrictions *model.ViewUse return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetTrueUpProfile() (map[string]any, error) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetTrueUpProfile") + + 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.GetTrueUpProfile() + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetUploadSession(c request.CTX, uploadId string) (*model.UploadSession, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUploadSession") diff --git a/app/true_up.go b/app/true_up.go new file mode 100644 index 0000000000..67e1ed01f8 --- /dev/null +++ b/app/true_up.go @@ -0,0 +1,168 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "strings" + "time" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/services/telemetry" + "github.com/mattermost/mattermost-server/v6/store" + "github.com/mattermost/mattermost-server/v6/utils" +) + +func pluginActivated(pluginStates map[string]*model.PluginState, pluginId string) bool { + state, ok := pluginStates[pluginId] + if !ok { + return false + } + return state.Enable +} + +func (a *App) getMarketplacePlugins() ([]string, error) { + ts := a.Srv().telemetryService + config := a.Srv().Config() + + marketplacePlugins, err := ts.GetAllMarketplacePlugins(model.PluginSettingsDefaultMarketplaceURL) + if err != nil { + return nil, err + } + + activePlugins := []string{} + for _, p := range marketplacePlugins { + id := p.Manifest.Id + if pluginActivated(config.PluginSettings.PluginStates, id) { + activePlugins = append(activePlugins, id) + } + } + + return activePlugins, nil +} + +func (a *App) getTrueUpProfile() (*model.TrueUpReviewProfile, error) { + + license := a.Channels().License() + if license == nil { + return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.license_required", nil, "Could not get the total active users count", http.StatusInternalServerError) + } + + // Customer Info & Usage Analytics + activeUserCount, err := a.Srv().Store().Status().GetTotalActiveUsersCount() + if err != nil { + return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.user_count_fail", nil, "Could not get the total active users count", http.StatusInternalServerError) + } + + // Webhook, calls, boards, and playbook counts + incomingWebhookCount, err := a.Srv().Store().Webhook().AnalyticsIncomingCount("") + if err != nil { + return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.webhook_in_count_fail", nil, "Could not get the total incoming webhook count", http.StatusInternalServerError) + } + outgoingWebhookCount, err := a.Srv().Store().Webhook().AnalyticsOutgoingCount("") + if err != nil { + return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.webhook_out_count_fail", nil, "Could not get the total outgoing webhook count", http.StatusInternalServerError) + } + + // Plugin Data + trueUpReviewPlugins := model.TrueUpReviewPlugins{ + PluginNames: []string{}, + } + + if plugins, err := a.getMarketplacePlugins(); err == nil { + trueUpReviewPlugins.PluginNames = plugins + trueUpReviewPlugins.TotalPlugins = len(plugins) + } + + // Authentication Features + config := a.Config() + mfaUsed := config.ServiceSettings.EnforceMultifactorAuthentication + ldapUsed := config.LdapSettings.Enable + samlUsed := config.SamlSettings.Enable + openIdUsed := config.OpenIdSettings.Enable + guestAccessAllowed := config.GuestAccountsSettings.Enable + + authFeatures := map[string]*bool{ + model.TrueUpReviewAuthFeaturesMfa: mfaUsed, + model.TrueUpReviewAuthFeaturesADLdap: ldapUsed, + model.TrueUpReviewAuthFeaturesSaml: samlUsed, + model.TrueUpReviewAuthFeatureOpenId: openIdUsed, + model.TrueUpReviewAuthFeatureGuestAccess: guestAccessAllowed, + } + + authFeatureList := []string{} + for feature, used := range authFeatures { + if used != nil && *used { + authFeatureList = append(authFeatureList, feature) + } + } + + reviewProfile := model.TrueUpReviewProfile{ + ServerId: a.TelemetryId(), + ServerVersion: model.CurrentVersion, + ServerInstallationType: os.Getenv(telemetry.EnvVarInstallType), + LicenseId: license.Id, + LicensedSeats: *license.Features.Users, + LicensePlan: license.SkuName, + CustomerName: license.Customer.Name, + ActiveUsers: activeUserCount, + TotalIncomingWebhooks: incomingWebhookCount, + TotalOutgoingWebhooks: outgoingWebhookCount, + Plugins: trueUpReviewPlugins, + AuthenticationFeatures: authFeatureList, + } + + return &reviewProfile, nil + +} + +func (a *App) GetTrueUpProfile() (map[string]any, error) { + profile, err := a.getTrueUpProfile() + + if err != nil { + return nil, err + } + + profileJson, err := json.Marshal(profile) + if err != nil { + return nil, err + } + telemetryProperties := map[string]any{} + + json.Unmarshal(profileJson, &telemetryProperties) + delete(telemetryProperties, "plugins") + plugins := profile.Plugins.ToMap() + for key, pluginValue := range plugins { + telemetryProperties[key] = pluginValue + } + + delete(telemetryProperties, "authentication_features") + telemetryProperties["authentication_features"] = strings.Join(profile.AuthenticationFeatures, ",") + + return telemetryProperties, nil +} + +func (a *App) GetOrCreateTrueUpReviewStatus() (*model.TrueUpReviewStatus, *model.AppError) { + nextDueDate := utils.GetNextTrueUpReviewDueDate(time.Now()) + status, err := a.Srv().Store().TrueUpReview().GetTrueUpReviewStatus(nextDueDate.UnixMilli()) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + a.Log().Warn("Could not find true up review status") + default: + return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.get_status_error", nil, "Could not get true up status records", http.StatusInternalServerError).Wrap(err) + } + + status, err = a.Srv().Store().TrueUpReview().CreateTrueUpReviewStatusRecord(&model.TrueUpReviewStatus{DueDate: nextDueDate.UnixMilli(), Completed: false}) + if err != nil { + return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.create_error", nil, "Could not create true up status record", http.StatusInternalServerError) + } + } + + return status, nil +} diff --git a/db/migrations/migrations.list b/db/migrations/migrations.list index a7fffb38f3..dab9088afb 100644 --- a/db/migrations/migrations.list +++ b/db/migrations/migrations.list @@ -200,6 +200,8 @@ db/migrations/mysql/000099_create_drafts.down.sql db/migrations/mysql/000099_create_drafts.up.sql db/migrations/mysql/000100_add_draft_priority_column.down.sql db/migrations/mysql/000100_add_draft_priority_column.up.sql +db/migrations/mysql/000101_create_true_up_review_history.down.sql +db/migrations/mysql/000101_create_true_up_review_history.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 @@ -400,3 +402,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..e1e16a7f70 --- /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..5b25ffdb80 --- /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 bigint(20), + 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..640d3cd87a --- /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 bigint, + completed boolean, + PRIMARY KEY (duedate) +); diff --git a/einterfaces/cloud.go b/einterfaces/cloud.go index 0c70c02cf3..bfaabae67e 100644 --- a/einterfaces/cloud.go +++ b/einterfaces/cloud.go @@ -45,4 +45,6 @@ type CloudInterface interface { CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) HandleLicenseChange() error + + CheckCWSConnection(userId string) error } diff --git a/einterfaces/mocks/CloudInterface.go b/einterfaces/mocks/CloudInterface.go index a447399cd8..4b19465161 100644 --- a/einterfaces/mocks/CloudInterface.go +++ b/einterfaces/mocks/CloudInterface.go @@ -60,6 +60,20 @@ func (_m *CloudInterface) ChangeSubscription(userID string, subscriptionID strin return r0, r1 } +// CheckCWSConnection provides a mock function with given fields: userId +func (_m *CloudInterface) CheckCWSConnection(userId string) error { + ret := _m.Called(userId) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(userId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // ConfirmCustomerPayment provides a mock function with given fields: userID, confirmRequest func (_m *CloudInterface) ConfirmCustomerPayment(userID string, confirmRequest *model.ConfirmPaymentMethodRequest) error { ret := _m.Called(userID, confirmRequest) diff --git a/i18n/en.json b/i18n/en.json index f6f7237066..36040c09de 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2077,6 +2077,34 @@ "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.create_error", + "translation": "Could not create true up status record" + }, + { + "id": "api.license.true_up_review.get_status_error", + "translation": "Could not get true up status records" + }, + { + "id": "api.license.true_up_review.license_required", + "translation": "True up review requires a license" + }, + { + "id": "api.license.true_up_review.not_allowed_for_cloud", + "translation": "True up review is not allowed for cloud instances" + }, + { + "id": "api.license.true_up_review.user_count_fail", + "translation": "Could not get the total active users count" + }, + { + "id": "api.license.true_up_review.webhook_in_count_fail", + "translation": "Could not get the total incoming webhook count" + }, + { + "id": "api.license.true_up_review.webhook_out_count_fail", + "translation": "Could not get the total outgoing webhook count" + }, { "id": "api.license.upgrade_needed.app_error", "translation": "Feature requires an upgrade to Enterprise Edition." @@ -2543,6 +2571,10 @@ "id": "api.scheme.patch_scheme.license.error", "translation": "Your license does not support update permissions schemes" }, + { + "id": "api.server.cws.health_check.app_error", + "translation": "CWS Server is not available." + }, { "id": "api.server.hosted_signup_unavailable.error", "translation": "Portal unavailable for self-hosted signup." diff --git a/model/client4.go b/model/client4.go index 298334d71a..64bf163312 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8700,6 +8700,17 @@ func (c *Client4) AddUserToGroupSyncables(userID string) (*Response, error) { return BuildResponse(r), nil } +func (c *Client4) CheckCWSConnection(userId string) (*Response, error) { + r, err := c.DoAPIGet(c.cloudRoute()+"/healthz", "") + + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + + return BuildResponse(r), nil +} + // Worktemplates sections func (c *Client4) worktemplatesRoute() string { diff --git a/model/license.go b/model/license.go index cf5c30a258..7c875dad32 100644 --- a/model/license.go +++ b/model/license.go @@ -39,6 +39,15 @@ var ( sanctionedTrialDurationUpperBound = 29*(time.Hour*24) + (time.Hour * 23) + (time.Minute * 59) + (time.Second * 59) // 696 hours (29 days) + 23 hours, 59 mins and 59 seconds ) +const ( + TrueUpReviewTelemetryName = "true_up_review_sent" + TrueUpReviewAuthFeaturesMfa = "multi_factor_authentication" + TrueUpReviewAuthFeaturesADLdap = "ad_ldap_sign_in" + TrueUpReviewAuthFeaturesSaml = "saml_sign_in" + TrueUpReviewAuthFeatureOpenId = "openid_connect" + TrueUpReviewAuthFeatureGuestAccess = "guest_access" +) + type LicenseRecord struct { Id string `json:"id"` CreateAt int64 `json:"create_at"` diff --git a/model/true_up_review_profile.go b/model/true_up_review_profile.go new file mode 100644 index 0000000000..8e4d186b75 --- /dev/null +++ b/model/true_up_review_profile.go @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import "strings" + +type TrueUpReviewProfile struct { + ServerId string `json:"server_id"` + ServerVersion string `json:"server_version"` + ServerInstallationType string `json:"server_installation_type"` + LicenseId string `json:"license_id"` + LicensedSeats int `json:"licensed_seats"` + LicensePlan string `json:"license_plan"` + CustomerName string `json:"customer_name"` + ActiveUsers int64 `json:"active_users"` + AuthenticationFeatures []string `json:"authentication_features"` + Plugins TrueUpReviewPlugins `json:"plugins"` + TotalIncomingWebhooks int64 `json:"incoming_webhooks_count"` + TotalOutgoingWebhooks int64 `json:"outgoing_webhooks_count"` +} + +type TrueUpReviewPlugins struct { + TotalPlugins int `json:"total_plugins"` + PluginNames []string `json:"plugin_names"` +} + +func (t *TrueUpReviewPlugins) ToMap() map[string]interface{} { + return map[string]interface{}{ + "total_plugins": t.TotalPlugins, + "plugin_names": strings.Join(t.PluginNames, ","), + } +} + +type TrueUpReviewStatus struct { + Completed bool `json:"complete"` + DueDate int64 `json:"due_date"` +} + +func (t *TrueUpReviewStatus) ToSlice() []interface{} { + return []interface{}{ + t.DueDate, + t.Completed, + } +} diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index 860960c379..0e17033d86 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -1421,7 +1421,7 @@ func (ts *TelemetryService) trackPluginConfig(cfg *model.Config, marketplaceURL "focalboard", } - marketplacePlugins, err := ts.getAllMarketplaceplugins(marketplaceURL) + marketplacePlugins, err := ts.GetAllMarketplacePlugins(marketplaceURL) if err != nil { mlog.Info("Failed to fetch marketplace plugins for telemetry. Using predefined list.", mlog.Err(err)) @@ -1459,7 +1459,7 @@ func (ts *TelemetryService) trackPluginConfig(cfg *model.Config, marketplaceURL ts.SendTelemetry(TrackConfigPlugin, pluginConfigData) } -func (ts *TelemetryService) getAllMarketplaceplugins(marketplaceURL string) ([]*model.BaseMarketplacePlugin, error) { +func (ts *TelemetryService) GetAllMarketplacePlugins(marketplaceURL string) ([]*model.BaseMarketplacePlugin, error) { marketplaceClient, err := marketplace.NewClient( marketplaceURL, ts.srv.HTTPService(), diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 129ef5254b..289b7f9582 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,60 @@ 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 int64) (*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 *OpenTracingLayerTrueUpReviewStore) Update(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "TrueUpReviewStore.Update") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.TrueUpReviewStore.Update(reviewStatus) + 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 +12904,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 065a1400a9..c474c10718 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,69 @@ 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 int64) (*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 *RetryLayerTrueUpReviewStore) Update(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) { + + tries := 0 + for { + result, err := s.TrueUpReviewStore.Update(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 *RetryLayerUploadSessionStore) Delete(id string) error { tries := 0 @@ -14630,6 +14703,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/retrylayer/retrylayer_test.go b/store/retrylayer/retrylayer_test.go index 701cf962f4..1bb8a677e6 100644 --- a/store/retrylayer/retrylayer_test.go +++ b/store/retrylayer/retrylayer_test.go @@ -57,6 +57,7 @@ func genStore() *mocks.Store { mock.On("Draft").Return(&mocks.DraftStore{}) mock.On("PostPriority").Return(&mocks.PostPriorityStore{}) mock.On("PostAcknowledgement").Return(&mocks.PostAcknowledgementStore{}) + mock.On("TrueUpReview").Return(&mocks.TrueUpReviewStore{}) return mock } diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 4e5641c219..0634e43bc5 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 + trueUpReview 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.trueUpReview = newSqlTrueUpReviewStore(store) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -984,6 +986,10 @@ func (ss *SqlStore) PostAcknowledgement() store.PostAcknowledgementStore { return ss.stores.postAcknowledgement } +func (ss *SqlStore) TrueUpReview() store.TrueUpReviewStore { + return ss.stores.trueUpReview +} + 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..23688ce5c1 --- /dev/null +++ b/store/sqlstore/true_up_review_store.go @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + "strconv" + + "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 int64) (*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 { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("TrueUpReviewStatus", strconv.FormatInt(dueDate, 10)) + } + + return nil, err + } + + 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=%d", reviewStatus.DueDate) + } + + return reviewStatus, nil +} diff --git a/store/sqlstore/true_up_review_store_test.go b/store/sqlstore/true_up_review_store_test.go new file mode 100644 index 0000000000..08b8ff810a --- /dev/null +++ b/store/sqlstore/true_up_review_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 TestTrueUpReviewStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestTrueUpReviewStatusStore) +} diff --git a/store/store.go b/store/store.go index 8a1c69d91a..a752cb784a 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,12 @@ type PostAcknowledgementStore interface { Delete(acknowledgement *model.PostAcknowledgement) error } +type TrueUpReviewStore interface { + GetTrueUpReviewStatus(dueDate int64) (*model.TrueUpReviewStatus, error) + CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) + Update(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..0e7bc15411 --- /dev/null +++ b/store/storetest/mocks/TrueUpReviewStore.go @@ -0,0 +1,84 @@ +// 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 int64) (*model.TrueUpReviewStatus, error) { + ret := _m.Called(dueDate) + + var r0 *model.TrueUpReviewStatus + if rf, ok := ret.Get(0).(func(int64) *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(int64) error); ok { + r1 = rf(dueDate) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: reviewStatus +func (_m *TrueUpReviewStore) Update(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 +} diff --git a/store/storetest/store.go b/store/storetest/store.go index e873ae4994..683ea3fbf8 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,7 @@ 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/storetest/true_up_review_store.go b/store/storetest/true_up_review_store.go new file mode 100644 index 0000000000..a2ba8ce0ce --- /dev/null +++ b/store/storetest/true_up_review_store.go @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + "time" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/store" + "github.com/mattermost/mattermost-server/v6/utils" + "github.com/stretchr/testify/assert" +) + +func TestTrueUpReviewStatusStore(t *testing.T, ss store.Store, s SqlStore) { + t.Run("CreateTrueUpReviewStatusRecord", func(t *testing.T) { testCreateTrueUpReviewStatus(t, ss) }) + t.Run("GetTrueUpReviewStatus", func(t *testing.T) { testGetTrueUpReviewStatus(t, ss) }) + t.Run("Update", func(t *testing.T) { testUpdateTrueUpReviewStatus(t, ss) }) +} + +func testCreateTrueUpReviewStatus(t *testing.T, ss store.Store) { + + now := time.Date(time.Now().Year(), time.January, 1, 0, 0, 0, 0, time.Local) + + reviewStatus := model.TrueUpReviewStatus{ + Completed: true, + DueDate: utils.GetNextTrueUpReviewDueDate(now).UnixMilli(), + } + + t.Run("create true up review status", func(t *testing.T) { + resp, err := ss.TrueUpReview().CreateTrueUpReviewStatusRecord(&reviewStatus) + assert.NoError(t, err) + + assert.Equal(t, reviewStatus.Completed, resp.Completed) + assert.Equal(t, reviewStatus.DueDate, resp.DueDate) + }) +} + +func testGetTrueUpReviewStatus(t *testing.T, ss store.Store) { + + now := time.Date(time.Now().Year(), time.August, 1, 0, 0, 0, 0, time.Local) + dueDate := utils.GetNextTrueUpReviewDueDate(now).UnixMilli() + + reviewStatus := model.TrueUpReviewStatus{ + Completed: true, + DueDate: dueDate, + } + + _, err := ss.TrueUpReview().CreateTrueUpReviewStatusRecord(&reviewStatus) + assert.NoError(t, err) + + t.Run("get true up review status", func(t *testing.T) { + resp, err := ss.TrueUpReview().GetTrueUpReviewStatus(dueDate) + assert.NoError(t, err) + + assert.Equal(t, resp.Completed, resp.Completed) + assert.Equal(t, resp.DueDate, resp.DueDate) + }) +} + +func testUpdateTrueUpReviewStatus(t *testing.T, ss store.Store) { + + now := time.Date(time.Now().Year(), time.April, 1, 0, 0, 0, 0, time.Local) + + reviewStatus := model.TrueUpReviewStatus{ + Completed: false, + DueDate: utils.GetNextTrueUpReviewDueDate(now).UnixMilli(), + } + + _, err := ss.TrueUpReview().CreateTrueUpReviewStatusRecord(&reviewStatus) + assert.NoError(t, err) + + t.Run("save ", func(t *testing.T) { + reviewStatus.Completed = true + resp, err := ss.TrueUpReview().Update(&reviewStatus) + assert.NoError(t, err) + + assert.Equal(t, resp.Completed, resp.Completed) + assert.Equal(t, resp.DueDate, resp.DueDate) + }) +} diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 19ccaf656d..dfd2e145a2 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,54 @@ 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 int64) (*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 *TimerLayerTrueUpReviewStore) Update(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) { + start := time.Now() + + result, err := s.TrueUpReviewStore.Update(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.Update", success, elapsed) + } + return result, err +} + func (s *TimerLayerUploadSessionStore) Delete(id string) error { start := time.Now() @@ -11573,6 +11631,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/true_up.go b/utils/true_up.go new file mode 100644 index 0000000000..1be96d86f8 --- /dev/null +++ b/utils/true_up.go @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package utils + +import ( + "time" +) + +const trueUpReviewDueDay = 15 +const day = time.Hour * 24 + +type DueDateWindow struct { + Start time.Time + End time.Time +} + +func GetNextTrueUpReviewDueDate(now time.Time) time.Time { + nowYear := now.Year() + nowMonth := now.Month() + nowDay := now.Day() + finalQuarterYear := nowYear + if nowMonth >= time.October && nowMonth <= time.December { + finalQuarterYear = nowYear + 1 + } + trueUpSubmissionWindows := []DueDateWindow{ + { + Start: time.Date(now.Year(), time.January, 16, 0, 0, 0, 0, now.Location()), + End: time.Date(now.Year(), time.April, 15, 0, 0, 0, 0, now.Location()), + }, + { + Start: time.Date(now.Year(), time.April, 16, 0, 0, 0, 0, now.Location()), + End: time.Date(now.Year(), time.July, 15, 0, 0, 0, 0, now.Location()), + }, + { + Start: time.Date(now.Year(), time.July, 16, 0, 0, 0, 0, now.Location()), + End: time.Date(now.Year(), time.October, 15, 0, 0, 0, 0, now.Location()), + }, + { + Start: time.Date(now.Year(), time.October, 16, 0, 0, 0, 0, now.Location()), + End: time.Date(finalQuarterYear, time.January, 15, 0, 0, 0, 0, now.Location()), + }, + } + + for _, window := range trueUpSubmissionWindows { + withinWindow := false + // Our due dates "wrap" around (i.e. can go into the next year), so we'll need to check the months different. Since January = 1 and December = 12, the checks + // for the current month being greater or equal to the start month and less than or equal to the end month will not work. + if window.End.Month() == time.January { + withinWindow = (nowMonth != time.January && nowMonth >= window.Start.Month()) || nowMonth == window.End.Month() + } else { + withinWindow = nowMonth >= window.Start.Month() && nowMonth <= window.End.Month() + } + + // Only check the days if the current month is equal to the start or end months. + // The dates of the middle month(s) don't matter so much. + isFirstMonth := nowMonth == window.Start.Month() + if isFirstMonth { + withinWindow = withinWindow && nowDay >= window.Start.Day() + } + isFinalMonth := nowMonth == window.End.Month() + if isFinalMonth { + withinWindow = withinWindow && nowDay <= window.End.Day() + } + + if withinWindow { + return window.End + } + } + + return trueUpSubmissionWindows[0].End +} + +func IsTrueUpReviewDueDateWithinTheNext30Days(now time.Time, dueDate time.Time) bool { + dueDateWindow := dueDate.Add(-day * 30) + + if now.Before(dueDateWindow) || now.After(dueDate) { + return false + } + + return true +} diff --git a/utils/true_up_test.go b/utils/true_up_test.go new file mode 100644 index 0000000000..3ba0085649 --- /dev/null +++ b/utils/true_up_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package utils + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGetNextTrueUpReviewDueDate(t *testing.T) { + t.Run("Due date always falls on the 15th", func(t *testing.T) { + // Before the 15th + now := time.Date(2022, time.March, 14, 0, 0, 0, 0, time.Local) + due := GetNextTrueUpReviewDueDate(now) + assert.Equal(t, trueUpReviewDueDay, due.Day()) + + // On the 15th + now = time.Date(2022, time.December, 15, 0, 0, 0, 0, time.Local) + due = GetNextTrueUpReviewDueDate(now) + assert.Equal(t, trueUpReviewDueDay, due.Day()) + + // After the 15th + now = time.Date(2022, time.September, 16, 0, 0, 0, 0, time.Local) + due = GetNextTrueUpReviewDueDate(now) + assert.Equal(t, trueUpReviewDueDay, due.Day()) + }) + + t.Run("Due date will always be in next quarter if the current date is past the 15th", func(t *testing.T) { + now := time.Date(2022, time.March, 16, 0, 0, 0, 0, time.Local) + due := GetNextTrueUpReviewDueDate(now) + assert.Equal(t, time.April, due.Month()) + + now = time.Date(2022, time.June, 16, 0, 0, 0, 0, time.Local) + due = GetNextTrueUpReviewDueDate(now) + assert.Equal(t, time.July, due.Month()) + + now = time.Date(2022, time.September, 16, 0, 0, 0, 0, time.Local) + due = GetNextTrueUpReviewDueDate(now) + assert.Equal(t, time.October, due.Month()) + + now = time.Date(2022, time.December, 16, 0, 0, 0, 0, time.Local) + due = GetNextTrueUpReviewDueDate(now) + assert.Equal(t, time.January, due.Month()) + }) + + t.Run("Due date will always be in the current quarter if the current date is before or on the 15th", func(t *testing.T) { + now := time.Date(2022, time.April, 15, 0, 0, 0, 0, time.Local) + due := GetNextTrueUpReviewDueDate(now) + assert.Equal(t, time.April, due.Month()) + + now = time.Date(2022, time.July, 15, 0, 0, 0, 0, time.Local) + due = GetNextTrueUpReviewDueDate(now) + assert.Equal(t, time.July, due.Month()) + + now = time.Date(2022, time.October, 14, 0, 0, 0, 0, time.Local) + due = GetNextTrueUpReviewDueDate(now) + assert.Equal(t, time.October, due.Month()) + + now = time.Date(2022, time.January, 14, 0, 0, 0, 0, time.Local) + due = GetNextTrueUpReviewDueDate(now) + assert.Equal(t, time.January, due.Month()) + }) + + t.Run("Due date will be in the next year if the next quarter is not within the current year", func(t *testing.T) { + now := time.Date(2022, time.October, 21, 0, 0, 0, 0, time.Local) + due := GetNextTrueUpReviewDueDate(now) + assert.Equal(t, time.January, due.Month()) + assert.Equal(t, 2023, due.Year()) + }) +} + +func TestIsTrueUpReviewDueDateWithinTheNext15Days(t *testing.T) { + t.Run("Ensure a date within 30 days before the due date returns true", func(t *testing.T) { + // 1 Day before the due date + now := time.Date(2022, time.March, 16, 0, 0, 0, 0, time.Local) + // Due date is December 15th, 2022 + due := GetNextTrueUpReviewDueDate(now) + + res := IsTrueUpReviewDueDateWithinTheNext30Days(now, due) + assert.True(t, res) + }) + + t.Run("Ensure a date that is more than two weeks before the due date returns false", func(t *testing.T) { + // 15 Days before the due date + now := time.Date(2022, time.October, 16, 0, 0, 0, 0, time.Local) + // Due date is December 15th, 2022 + due := GetNextTrueUpReviewDueDate(now) + + res := IsTrueUpReviewDueDateWithinTheNext30Days(now, due) + assert.False(t, res) + }) + + t.Run("Ensure a date that is past the due date returns false", func(t *testing.T) { + now := time.Date(2022, time.April, 15, 0, 0, 0, 0, time.Local) + + // Due date is April 16th, 2022 + dueNow := time.Date(2022, time.April, 16, 0, 0, 0, 0, time.Local) + due := GetNextTrueUpReviewDueDate(dueNow) + + res := IsTrueUpReviewDueDateWithinTheNext30Days(now, due) + assert.False(t, res) + }) + + t.Run("Ensure a date that is on the due date returns true", func(t *testing.T) { + now := time.Date(2022, time.January, 15, 0, 0, 0, 0, time.Local) + due := GetNextTrueUpReviewDueDate(now) + fmt.Printf("\n\ndue date: %s\n\n", due.Format("2006-Jan-02")) + fmt.Printf("\n\nnow: %s\n\n", now.Format("2006-Jan-02")) + + res := IsTrueUpReviewDueDateWithinTheNext30Days(now, due) + assert.True(t, res) + }) + + t.Run("Ensure a date that is on the first day of the due date window returns true", func(t *testing.T) { + now := time.Date(2022, time.December, 16, 0, 0, 0, 0, time.Local) + due := GetNextTrueUpReviewDueDate(now) + + res := IsTrueUpReviewDueDateWithinTheNext30Days(now, due) + assert.True(t, res) + }) +}