diff --git a/api4/license.go b/api4/license.go index 4bf6ed741c..56c4f50d38 100644 --- a/api4/license.go +++ b/api4/license.go @@ -7,14 +7,11 @@ import ( "bytes" b64 "encoding/base64" "encoding/json" - "errors" "fmt" "io" "net/http" - "time" "github.com/mattermost/mattermost-server/v6/shared/mlog" - "github.com/mattermost/mattermost-server/v6/store" "github.com/mattermost/mattermost-server/v6/utils" "github.com/mattermost/mattermost-server/v6/audit" @@ -303,31 +300,6 @@ func getPrevTrialLicense(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJSON(clientLicense))) } -func getOrCreateTrueUpReviewStatus(c *Context) (*model.TrueUpReviewStatus, bool) { - nextDueDate := utils.GetNextTrueUpReviewDueDate(time.Now()) - status, err := c.App.Srv().Store().TrueUpReview().GetTrueUpReviewStatus(nextDueDate.UnixMilli()) - if err != nil { - var nfErr *store.ErrNotFound - switch { - case errors.As(err, &nfErr): - c.Logger.Warn("Could not find true up review status") - default: - c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.get_status_error", nil, "Could not get true up status records", http.StatusInternalServerError).Wrap(err) - return nil, false - } - - status, err = c.App.Srv().Store().TrueUpReview().CreateTrueUpReviewStatusRecord(&model.TrueUpReviewStatus{DueDate: nextDueDate.UnixMilli(), Completed: false}) - if err != nil { - c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.create_error", nil, "Could not create true up status record", http.StatusInternalServerError) - return nil, false - } - } - - telemetryService := c.App.Srv().GetTelemetryService() - status.TelemetryEnabled = telemetryService.TelemetryEnabled() - return status, true -} - 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) { @@ -358,8 +330,9 @@ func requestTrueUpReview(c *Context, w http.ResponseWriter, r *http.Request) { return } - status, ok := getOrCreateTrueUpReviewStatus(c) - if !ok { + status, appErr := c.App.GetOrCreateTrueUpReviewStatus() + if err != nil { + c.Err = appErr return } @@ -404,9 +377,9 @@ func trueUpReviewStatus(c *Context, w http.ResponseWriter, r *http.Request) { return } - status, ok := getOrCreateTrueUpReviewStatus(c) - if !ok { - return + status, appErr := c.App.GetOrCreateTrueUpReviewStatus() + if appErr != nil { + c.Err = appErr } json, err := json.Marshal(status) diff --git a/app/app_iface.go b/app/app_iface.go index 4b197e2dd3..09f544c605 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) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 220f461921..4d9d37de6e 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") diff --git a/app/server.go b/app/server.go index fbd433a6db..2c6507c245 100644 --- a/app/server.go +++ b/app/server.go @@ -45,6 +45,7 @@ import ( "github.com/mattermost/mattermost-server/v6/jobs/import_process" "github.com/mattermost/mattermost-server/v6/jobs/last_accessible_file" "github.com/mattermost/mattermost-server/v6/jobs/last_accessible_post" + "github.com/mattermost/mattermost-server/v6/jobs/license_true_up" "github.com/mattermost/mattermost-server/v6/jobs/migrations" "github.com/mattermost/mattermost-server/v6/jobs/notify_admin" "github.com/mattermost/mattermost-server/v6/jobs/product_notices" @@ -1529,6 +1530,12 @@ func (s *Server) initJobs() { notify_admin.MakeScheduler(s.Jobs, s.License(), model.JobTypeTrialNotifyAdmin), ) + s.Jobs.RegisterJobType( + model.JobTypeLicenseTrueUpReview, + license_true_up.MakeWorker(s.Jobs, s.License(), New(ServerConnector(s.Channels())), s.telemetryService), + license_true_up.MakeScheduler(s.Jobs, s.License(), s.telemetryService), + ) + s.platform.Jobs = s.Jobs } diff --git a/app/true_up.go b/app/true_up.go index 790d7a8353..7240fc4e90 100644 --- a/app/true_up.go +++ b/app/true_up.go @@ -5,12 +5,16 @@ 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 (a *App) getTrueUpProfile() (*model.TrueUpReviewProfile, error) { @@ -117,3 +121,26 @@ func (a *App) GetTrueUpProfile() (map[string]any, error) { 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) + } + } + + telemetryService := a.Srv().GetTelemetryService() + status.TelemetryEnabled = telemetryService.TelemetryEnabled() + return status, nil +} diff --git a/jobs/license_true_up/scheduler.go b/jobs/license_true_up/scheduler.go new file mode 100644 index 0000000000..e5b7dae3ad --- /dev/null +++ b/jobs/license_true_up/scheduler.go @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package license_true_up + +import ( + "strconv" + "time" + + "github.com/mattermost/mattermost-server/v6/jobs" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/services/telemetry" + "github.com/mattermost/mattermost-server/v6/shared/mlog" +) + +const scheduleFrequency = time.Hour * 24 + +func MakeScheduler(jobServer *jobs.JobServer, license *model.License, telemetryService *telemetry.TelemetryService) model.Scheduler { + isEnabled := func(cfg *model.Config) bool { + enabled := license != nil && !*license.Features.Cloud && !license.IsTrialLicense() && telemetryService.TelemetryEnabled() + mlog.Debug("Scheduler: isEnabled: "+strconv.FormatBool(enabled), mlog.String("scheduler", model.JobTypeLicenseTrueUpReview)) + return enabled + } + return jobs.NewPeriodicScheduler(jobServer, model.JobTypeLicenseTrueUpReview, scheduleFrequency, isEnabled) +} diff --git a/jobs/license_true_up/worker.go b/jobs/license_true_up/worker.go new file mode 100644 index 0000000000..a9dc4a6e8e --- /dev/null +++ b/jobs/license_true_up/worker.go @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package license_true_up + +import ( + "time" + + "github.com/mattermost/mattermost-server/v6/jobs" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/services/telemetry" + "github.com/mattermost/mattermost-server/v6/utils" +) + +const ( + JobName = "LicenseTrueUpReview" +) + +type AppIface interface { + GetTrueUpProfile() (map[string]any, error) +} + +func MakeWorker(jobServer *jobs.JobServer, license *model.License, app AppIface, telemetryService *telemetry.TelemetryService) model.Worker { + isEnabled := func(_ *model.Config) bool { + return license != nil && !*license.Features.Cloud && !license.IsTrialLicense() && telemetryService.TelemetryEnabled() + } + + execute := func(job *model.Job) error { + defer jobServer.HandleJobPanic(job) + + // Ensure we are within the due date + dueDate := utils.GetNextTrueUpReviewDueDate(time.Now()) + if !utils.IsTrueUpReviewDueDateWithinTheNextTwoWeeks(time.Now(), dueDate) { + return nil + } + profile, err := app.GetTrueUpProfile() + if err != nil { + return err + } + + telemetryService.SendTelemetry(model.TrueUpReviewTelemetryName, profile) + + return nil + } + worker := jobs.NewSimpleWorker(JobName, jobServer, execute, isEnabled) + return worker +} diff --git a/model/job.go b/model/job.go index c63d260d85..b90892e4e1 100644 --- a/model/job.go +++ b/model/job.go @@ -31,6 +31,7 @@ const ( JobTypeLastAccessibleFile = "last_accessible_file" JobTypeUpgradeNotifyAdmin = "upgrade_notify_admin" JobTypeTrialNotifyAdmin = "trial_notify_admin" + JobTypeLicenseTrueUpReview = "license_true_up_review" JobStatusPending = "pending" JobStatusInProgress = "in_progress" diff --git a/utils/license.go b/utils/license.go index cca1978558..eb49b3e165 100644 --- a/utils/license.go +++ b/utils/license.go @@ -37,6 +37,8 @@ var LicenseValidator LicenseValidatorIface const trueUpReviewDueDay = 15 const businessQuarterStep = 3 +const day = time.Hour * 24 +const week = day * 7 func init() { if LicenseValidator == nil { @@ -245,3 +247,13 @@ func GetNextTrueUpReviewDueDate(now time.Time) time.Time { return time.Date(now.Year(), nextQuarterEndMonth, trueUpReviewDueDay, 0, 0, 0, 0, now.Location()) } + +func IsTrueUpReviewDueDateWithinTheNextTwoWeeks(now time.Time, dueDate time.Time) bool { + dueDateWindow := dueDate.Add(-(week * 2)) + + if now.Before(dueDateWindow) || now.After(dueDate) { + return false + } + + return true +} diff --git a/utils/license_test.go b/utils/license_test.go index 7e031200a4..57c57a6a3c 100644 --- a/utils/license_test.go +++ b/utils/license_test.go @@ -154,3 +154,52 @@ func TestGetNextTrueUpReviewDueDate(t *testing.T) { assert.Equal(t, 2023, due.Year()) }) } + +func TestIsTrueUpReviewDueDateWithinTheNextTwoWeeks(t *testing.T) { + t.Run("Ensure a date within two weeks before the due date returns true", func(t *testing.T) { + // 1 Day before the due date + now := time.Date(2022, time.December, 14, 0, 0, 0, 0, time.Local) + // Due date is December 15th, 2022 + due := GetNextTrueUpReviewDueDate(now) + + res := IsTrueUpReviewDueDateWithinTheNextTwoWeeks(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.November, 30, 0, 0, 0, 0, time.Local) + // Due date is December 15th, 2022 + due := GetNextTrueUpReviewDueDate(now) + + res := IsTrueUpReviewDueDateWithinTheNextTwoWeeks(now, due) + assert.False(t, res) + }) + + t.Run("Ensure a date that past the due date returns false", func(t *testing.T) { + now := time.Date(2022, time.December, 16, 0, 0, 0, 0, time.Local) + + // Due date is December 15th, 2022 + dueNow := time.Date(2022, time.December, 15, 0, 0, 0, 0, time.Local) + due := GetNextTrueUpReviewDueDate(dueNow) + + res := IsTrueUpReviewDueDateWithinTheNextTwoWeeks(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.December, 15, 0, 0, 0, 0, time.Local) + due := GetNextTrueUpReviewDueDate(now) + + res := IsTrueUpReviewDueDateWithinTheNextTwoWeeks(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, 1, 0, 0, 0, 0, time.Local) + due := GetNextTrueUpReviewDueDate(now) + + res := IsTrueUpReviewDueDateWithinTheNextTwoWeeks(now, due) + assert.True(t, res) + }) +}